diff --git a/README.md b/README.md index 1c3aa44f..fdff7739 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ checklist. | Rapoo VT9 Pro | `@openmouse/protocol/rapoo` | | Razer V4 | `@openmouse/protocol/razer-v4` | | Ryunix | `@openmouse/protocol/ryunix` | +| Amkette XS series | `@openmouse/protocol/amkette` | | SteelSeries Rival 3 (Gen 1) | `@openmouse/protocol/steelseries` | | Teevolution | `@openmouse/protocol/teevolution` | | VGN | `@openmouse/protocol/vgn` | diff --git a/docs/amkette-xs-flow-testing.md b/docs/amkette-xs-flow-testing.md new file mode 100644 index 00000000..7a84886a --- /dev/null +++ b/docs/amkette-xs-flow-testing.md @@ -0,0 +1,270 @@ +# Amkette XS Flow series and Ryunix Kyu Pro MX1 integration note + +This note records the evidence and the remaining checks for Amkette's XS +series on the `0x04F3` OEM firmware. The Amkette XS mice and the Ryunix Kyu Pro +MX1 enumerate with the same vendor and product IDs and, as far as the vendor +configurators show, speak the same protocol. The wire codec is shared and lives +in `src/ryunix/`; what identifies the Amkette models (IDs, usage, product strings, +Bluetooth names) is in `src/amkette/`, which imports the shared codec. The two +brands have separate drivers: + +- `src/drivers/amkette/xs-hid.ts` (`AmketteXsHidClient`) drives a mouse whose + configuration collection is on usage `0x00`, which is what an XS Flow 2 Speed + publishes on all three paths. It reads telemetry and writes settings. +- `src/drivers/ryunix/kyu-pro-mx1-hid.ts` (`KyuProMx1Client`) keeps the Kyu Pro + MX1's collection on usage `0xC7` and stays read-only. Its only change: the + shared decoder now accepts battery status 2, which it shows as full at 100% + rather than discharging. None + of the writes below have been checked on a Kyu Pro MX1, so they are not + enabled for it, the same way the Noir Gear M2-NEX was added without changing + the K-snake X11's behaviour. + +The usage is the only thing seen to differ, from one unit of each. If a Kyu Pro +MX1 turns out to publish usage `0x00`, it would get the Amkette driver and be +shown as Amkette; if an XS mouse publishes `0xC7`, it would get the read-only +one. A mouse publishing both usages has not been seen. Within one `HIDDevice` +the Amkette driver refuses it, but where WebHID exposes each top-level +collection as its own device (Windows), the two collections would appear as two +devices, one for each driver. + +## Identity and connection paths + +| Path | VID | PID | Usage page | Usage | +| --- | ---: | ---: | ---: | ---: | +| Bluetooth LE | `0x04F3` | `0x026D` | `0x0A` | `0x00` | +| USB | `0x04F3` | `0x026E` | `0x0A` | `0x00` | +| 2.4 GHz receiver | `0x04F3` | `0x026F` | `0x0A` | `0x00` | + +Amkette's configurator treats any product ID other than `0x026E` and `0x026F` +as Bluetooth; `0x026D` is the one seen on hardware. + +## Naming + +The firmware reports a USB product string (descriptor index 2). An owned XS Flow +2 Speed returns `XS Speed` in UTF-16 on a standard string-descriptor request. +That equals the label Amkette's configurator shows for the model; the XS Flow +Plus and XS Natural Plus configurators use `XS Flow+` and `XS Natural+`. + +The driver maps those labels and the Bluetooth names below to the Amkette model +(`amketteXsModel`), shows "Amkette" and the retail name, and drops the +lighting card for the XS Natural Plus (no lighting scheme). Retail names are not +matched, because the firmware has not been seen to report them. Only `XS Speed` +has been seen on hardware; the other two labels come from their configurators. + +macOS does not surface the string over USB for this mouse: `ioreg` and system +profiler list no product, manufacturer or serial, and the browser's +`productName` is empty, even though the descriptor read succeeds and survives a +replug. Over the 2.4 GHz receiver (`0x026F`) macOS does show a name, but it is +the generic `2.4G Wireless Receiver`, which names the dongle and not the mouse. + +Over Bluetooth LE (`0x026D`) the mouse is listed by macOS as `XS speed 2` +(manufacturer `RivieraWaves SAS`, the Bluetooth stack vendor, not Amkette). The +mouse holds three Bluetooth slots and the number in the name is the active slot: +this unit was on slot 2. The driver accepts `XS speed 1`, `2` and `3` as the XS +Flow 2 Speed and reports the slot in the connection detail, so a Bluetooth +connection is identified where the cable is not. Only slot 2 has been seen; slots +1 and 3 follow the owner's description, and whether they keep PID `0x026D` is +unchecked. + +The brand follows the same evidence that chooses the driver. A device on the +usage-`0x00` collection is driven by the Amkette driver, so it is shown as +Amkette; the product name only refines the model. With no model name it is +shown as "Amkette XS series". This differs from the Noir Gear M2-NEX on the +K-snake protocol, where the name is the only thing that tells the brands apart +and an unnamed device stays K-snake X11; here the collection's usage already +separates them. Receiver-style strings are not shown as the mouse name; any +other reported name is kept. + +In practice, on macOS the model name appears only over Bluetooth ("XS Flow 2 +Speed"). On the cable and the receiver the mouse is shown as "Amkette XS +series", with the XS Flow 2 Speed's settings. A model name is expected to arrive +over USB on platforms whose HID layer reads the string. On Windows it does: over +USB the browser reports `XS Speed` and OpenMouse shows "Amkette XS Flow 2 +Speed". + +## Evidence + +The protocol is reverse-engineered from Amkette's public WebHID configurators +(`support.amkette.com/webapp/xs_flow_2_speed/`, `xs_flow_plus/` and +`xs_natural_plus/`). The three are one build with different model settings. +No vendor binary or proprietary source is included. Amkette's other web +configurators use different IDs (Phantom Air Wireless, `0xB6A4`) or publish no +HID filters, and are not covered. + +Read-only probing of an owned, wired XS Flow 2 Speed produced: + +- input report `0x04` body `01 02 01 64 02 00 00 01 20`: link up, DPI stage 3, + 1000 Hz, battery 100, battery status 2, lighting off, top rate class 1, + sensor `0x20` (PixArt 3220); +- feature report `0x05`: `31 30 31 30 01 01` (looks like ASCII "1010"; meaning + unconfirmed, so not surfaced); +- feature report `0x06`: `01` in byte 1, the acknowledgement of success. + +Amkette's page showed "Full 100%" at the same time, which fixes battery status +`2` as full. + +The same mouse over its 2.4 GHz receiver (`0x026F`, bcdDevice 0x0301 against +0x0109 on the cable) sent body `01 02 01 64 00 02 00 01 20`: battery 100, +battery status 0 (discharging), 1000 Hz, DPI stage 3, lighting mode 2 +(Breathe, set earlier through OpenMouse) and the same sensor `0x20`. Feature +report `0x05` returned the same `31 30 31 30 01 01` as over USB, so it does not +identify a model. The HID layout matches the USB one. + +Over Bluetooth LE (`0x026D`, bcdDevice 0x0109) the control collection, with the +same reports `0x04`, `0x05`, `0x06` and `0xBC`-`0xBF`, sits on one composite HID +interface (only report `0xBF` is shorter, 39 bytes against 135). Feature report +`0x05` again returned `31 30 31 30 01 01`, and the telemetry body was +`01 02 04 64 00 02 00 31 20`: battery 100, 250 Hz, DPI stage 3, Breathe, top +rate class `0x31` and sensor `0x20`. Class `0x31` is the vendor app's "125 and +250 Hz only" class, which matches Bluetooth's lower polling ceiling. + +## Wire format + +Commands are 31-byte feature reports on report `0x06`: +`[command, sequence, sub, checksum, ...payload]`, where the checksum is +`(6 + sum of the other 30 bytes) & 0xFF`. Reading report `0x06` back returns +the acknowledgement (byte 1 equal to 1). Reading report `0x05` makes the mouse +emit telemetry on input report `0x04`; the mouse sends telemetry on request, not +on change. The codec's golden vectors re-implement the vendor app's own framing +independently. + +## Verified on hardware (XS Flow 2 Speed) + +- the control collection is offered by the picker and opens over USB, on macOS + and on Windows; +- on Windows over USB the browser reports the product name `XS Speed`, and the + mouse is shown as Amkette XS Flow 2 Speed; +- telemetry decodes on USB, the 2.4 GHz receiver and Bluetooth LE: battery, + battery status, DPI stage, polling rate, lighting mode, top polling class and + sensor type; +- over USB, a polling-rate write through OpenMouse is acknowledged and confirmed + by a fresh status report, and a Breathe lighting write is applied (the mouse + reports lighting mode 2 afterwards); +- lift-off writes are acknowledged over Bluetooth and the receiver, and lighting + effect writes over the receiver (probe tool, see the sections below); +- DPI over USB: choosing a stage in OpenMouse writes the stage table and + switches stage, confirmed by the status report and the mouse's stage light, + and pressing the mouse's DPI button is followed in OpenMouse. + +## Not yet verified + +These are encoded from the vendor app and covered by codec tests, but have not +been checked against the mouse: editing a single stage's value, stage colours, +auto sleep, angle snapping, motion sync, ripple control and button remapping. Whether lift-off, brightness and effect speed change anything +physically has not been recorded. Writes through OpenMouse over the receiver and +Bluetooth (including DPI), polling rates above 1000 Hz, and the XS Flow Plus and XS Natural Plus +models are untested. Bluetooth is claimed on the strength of read-only probes +plus acknowledged lift-off writes from one unit on slot 2; slots 1 and 3, and +whether they keep PID `0x026D`, are unchecked. + +## Lift-off is not readable (tested) + +A differential test over Bluetooth wrote only the lift-off command (1 mm, then +2 mm, ending on 2 mm) and dumped every readable feature report before and after. +Reports `0x05` and `0x06` did not change apart from the acknowledgement +(`06 00` became `06 01`). Reports `0xBC`, `0xBD` and `0xBF` changed, but they +mirror the last command frame the mouse received, report id included: before the +test they held `06 08 07 01 ff` (an earlier sensor-options command from the +vendor page), after the 1 mm write `06 05 71 01 64 01`, and after the 2 mm write +`06 05 72 01 66 02`. They echo the most recent command, not a setting, so they +confirm receipt but cannot tell the current lift-off. Report `0xBE` refuses +reads. The status report carries no lift-off field. Lift-off is therefore +write-only on this firmware, like DPI values, sleep and the sensor toggles. + +The same test over the 2.4 GHz receiver (`0x026F`) found no readback at all: +`0xBC`-`0xBF` return no data there, the status report and report `0x05` did not +change between 1 mm and 2 mm, and the acknowledgement lags. After a write the +acknowledgement read `06 00` at 60 ms and `06 01` from 150 ms, and one write in +the first run never showed `01`; a re-send was acknowledged. The driver therefore +polls the acknowledgement for up to 600 ms before re-sending. Bluetooth +acknowledged immediately. Lift-off ended on 2 mm on both paths. + +The macOS `IOHIDDeviceSetReport` call used for the Bluetooth test returned a generic +error code (`0xe00002bc`) for both writes even though the mouse acknowledged +them and echoed the frames; that code was not investigated. + +## Brightness and effect speed are not readable (tested) + +A differential test over the 2.4 GHz receiver wrote only lighting commands and +read the status report after each: Steady at 100% (byte `0x40`) and 25% +(`0x10`), then Breathe at speed 1 (`0x01`) and speed 3 (`0x03`). Every command +was acknowledged on the first attempt. The lighting-mode byte of the status +report followed the effect (3, 3, 3, 2, 2), but no other byte of the status +report, report `0x05`, or the acknowledgement differed between 100% and 25% or +between speed 1 and speed 3. Brightness and speed are therefore write-only: the +mouse accepts the commands, but nothing readable shows whether it applies them. +Whether the light changes has to be judged by eye; that has not been recorded. +The mouse was found on Steady before the test and left on Breathe at speed 3. + +## Write-only settings + +The mouse reports only battery, active DPI stage, polling rate, lighting mode, +top polling class and sensor type. DPI values, stage colours, lift-off, sleep, +the three sensor toggles and button assignments cannot be read back. The driver +shows what it last wrote this session and, before any write, the vendor app's +defaults (seven DPI stages from the sensor table, 2 mm lift-off, one minute +sleep, motion sync on). The status note says so. + +Consequences worth knowing: + +- the DPI table is written whole: the first DPI edit replaces all stages with + the defaults plus the edit; +- the active stage can only be chosen by writing the whole table, so choosing a + stage in OpenMouse writes the table it shows (the vendor's defaults until + something else is written) with that stage active, as Amkette's configurator + does on every stage click. Stages stored on the mouse by another tool are + replaced. An earlier version refused stage selection until a table had been + written, but clicking a stage number is OpenMouse's main way to pick a DPI, so + on Windows that showed an error on the first DPI change. OpenMouse reads which + methods a client has once, when the device connects, so the driver cannot + hide stage selection for part of a session; a UI hint for that would be an + OpenMouse change; +- DPI is not written until the mouse has reported its sensor type, since the + wire value counts in steps of 50 or 100 depending on the sensor; +- the three sensor toggles are written together, so changing one writes the + others' last known (or default) values; +- a button write carries all ten positions. Positions not changed this session, + including the bottom mode switch, are written as the vendor app's default + layout, as the vendor app does, so a layout stored by another tool is reset. + 0xFF ("not configured" in the vendor app) is not used, because what the + firmware does with it is unknown; +- what was written is remembered per product ID for the session only, so the + same mouse on the cable, the receiver and Bluetooth starts fresh on each path, + and two mice on the same path share it; +- the acknowledgement on report `0x06` carries no sequence number. The driver + assumes it reflects the newest command; the receiver test supports this (it + reads `06 00` right after a new command, before turning `06 01`), but nothing + checks it. Over Bluetooth the `0xBC`-`0xBF` echo could, and is not used yet. + +## Behaviour observed on hardware + +- The Breathe light takes the colour of the active DPI stage. No lighting + effect has a colour of its own, so the driver offers no lighting colour + picker. Changing a stage colour is supported by the driver + (`setDpiStageColor`) but OpenMouse has no control for it yet. + +## Scope choices + +- Lighting offers Off, Breathing and Static, the XS Flow 2 Speed scheme. A mouse + named as the XS Natural Plus gets no lighting. The XS Flow Plus's seven + effects (scheme 3) are in the codec but not offered even when the model is + named, because they have not been mapped to OpenMouse's effects or checked; + it gets the three-effect set. +- Lift-off is offered as Low (1 mm) and High (2 mm), the vendor app's two + choices. +- The bottom mode-switch button is not remappable. +- Macros and factory reset are not wired in. The reset frame exists in the codec + for completeness; do not use factory reset during initial testing. + +## OpenMouse changes needed + +Auto sleep appears only when OpenMouse's `src/device/traits.ts` lists the +`amkette-xs` family with `sleep: true` and the driver supplies +`getSleepOptions()` (one to fifteen minutes, in seconds). That entry lives in the +`openmouse` repository and should land in the same release as the package bump. + +Nothing else in OpenMouse depends on the family name. The app looks the family +up only in that traits table and for a few other brands' own cards; lift-off, +buttons, the DPI stage editor, lighting and the processing card are shown from +the status fields. Without the entry, only the sleep card is missing. The Ryunix +driver keeps its `ryunix` family and needs no OpenMouse change. diff --git a/package.json b/package.json index df2a8035..ce78ee77 100644 --- a/package.json +++ b/package.json @@ -202,6 +202,10 @@ "types": "./dist/ryunix/index.d.ts", "import": "./dist/ryunix/index.js" }, + "./amkette": { + "types": "./dist/amkette/index.d.ts", + "import": "./dist/amkette/index.js" + }, "./rapoo": { "types": "./dist/rapoo/index.d.ts", "import": "./dist/rapoo/index.js" diff --git a/src/amkette/index.ts b/src/amkette/index.ts new file mode 100644 index 00000000..a1346515 --- /dev/null +++ b/src/amkette/index.ts @@ -0,0 +1 @@ +export * from "./xs.js"; diff --git a/src/amkette/xs.test.ts b/src/amkette/xs.test.ts new file mode 100644 index 00000000..7547cdda --- /dev/null +++ b/src/amkette/xs.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AMKETTE_XS_BLUETOOTH_PRODUCT_ID, + AMKETTE_XS_PRODUCT_IDS, + AMKETTE_XS_USAGE, + amketteBluetoothSlot, + amketteIsReceiverName, + amketteXsModel, +} from "./xs.ts"; + +test("the XS IDs add Bluetooth to the two shared product IDs, on usage 0x00", () => { + assert.equal(AMKETTE_XS_BLUETOOTH_PRODUCT_ID, 0x026d); + assert.deepEqual([...AMKETTE_XS_PRODUCT_IDS].sort(), [0x026d, 0x026e, 0x026f]); + assert.equal(AMKETTE_XS_USAGE, 0x00); +}); + +test("Amkette models are found only by the strings the firmware reports", () => { + assert.equal(amketteXsModel("XS Speed")?.name, "XS Flow 2 Speed"); + assert.equal(amketteXsModel("XS Speed")?.lightingScheme, 1); + assert.equal(amketteXsModel("XS speed 2")?.name, "XS Flow 2 Speed", "the Bluetooth LE name"); + assert.equal(amketteXsModel("XS Flow+")?.name, "XS Flow Plus"); + assert.equal(amketteXsModel("XS Natural+")?.lightingScheme, 4); + // Retail names are not reported by the firmware, so they are not matched. + assert.equal(amketteXsModel("XS Flow 2 Speed"), null); + assert.equal(amketteXsModel("XS Natural Plus"), null); + assert.equal(amketteXsModel(""), null); + assert.equal(amketteXsModel(null), null); + assert.equal(amketteXsModel("Kyu Pro MX1"), null); +}); + +test("receiver strings are generic, model names are not", () => { + assert.equal(amketteIsReceiverName("2.4G Wireless Receiver"), true); + assert.equal(amketteIsReceiverName("USB Dongle"), true); + assert.equal(amketteIsReceiverName("XS Speed"), false); + assert.equal(amketteIsReceiverName(""), false); + assert.equal(amketteIsReceiverName(null), false); +}); + +test("the Bluetooth name carries the slot of the three the mouse holds", () => { + for (const slot of [1, 2, 3]) { + assert.equal(amketteXsModel(`XS speed ${slot}`)?.name, "XS Flow 2 Speed"); + assert.equal(amketteBluetoothSlot(`XS speed ${slot}`), slot); + } + assert.equal(amketteBluetoothSlot("XS speed 4"), null); + assert.equal(amketteBluetoothSlot("XS speed"), null, "the USB label has no slot"); + assert.equal(amketteBluetoothSlot("XS speed 22"), null); + assert.equal(amketteXsModel("XS speed 4"), null); + assert.equal(amketteBluetoothSlot("Kyu Pro MX1"), null); + assert.equal(amketteBluetoothSlot(null), null); +}); diff --git a/src/amkette/xs.ts b/src/amkette/xs.ts new file mode 100644 index 00000000..0b2135ac --- /dev/null +++ b/src/amkette/xs.ts @@ -0,0 +1,103 @@ +/** + * Amkette XS series on the 0x04F3 OEM firmware it shares with the Ryunix Kyu Pro + * MX1. The wire protocol, telemetry and command encoders are shared and live in + * `@openmouse/protocol/ryunix`; this module holds only what identifies the + * Amkette models: their IDs, collection usage, product strings and Bluetooth + * names. See `docs/amkette-xs-flow-testing.md` for the evidence. + */ + +import { RYUNIX_WIRED_PRODUCT_ID, RYUNIX_WIRELESS_PRODUCT_ID } from "../ryunix/kyu-pro-mx1.ts"; + +/** + * Bluetooth LE product ID of the Amkette XS Flow 2 Speed. Amkette's + * configurator treats any ID other than 0x026E and 0x026F as Bluetooth. + */ +export const AMKETTE_XS_BLUETOOTH_PRODUCT_ID = 0x026d; +/** + * The Amkette XS firmware publishes the same configuration channel on usage + * 0x00 of page 0x0A, on all three paths, where the Kyu Pro MX1 contribution + * saw usage 0xC7. The usage keeps the two drivers apart. + */ +export const AMKETTE_XS_USAGE = 0x00; +export const AMKETTE_XS_PRODUCT_IDS: ReadonlySet = new Set([ + AMKETTE_XS_BLUETOOTH_PRODUCT_ID, + RYUNIX_WIRED_PRODUCT_ID, + RYUNIX_WIRELESS_PRODUCT_ID, +]); + +export interface AmketteXsModel { + /** Retail name shown to the user. */ + name: string; + /** The USB product string the model reports, as Amkette's configurator labels it. */ + productStrings: readonly string[]; + /** + * Base of the Bluetooth LE name; the mouse keeps three Bluetooth slots and + * appends the active slot, e.g. "XS speed 2". Null where not known. + */ + bluetoothLabel: string | null; + /** Lighting scheme in the vendor app (`RYUNIX_LIGHTING_SCHEMES` in `@openmouse/protocol/ryunix`). */ + lightingScheme: number; + /** True once an owned unit has been seen reporting `productStrings`. */ + verified: boolean; +} + +/** + * Amkette XS models on this firmware. Each configurator names its device with a + * short label; an owned XS Flow 2 Speed reports that label ("XS Speed") as its + * USB product string on the cable. Over Bluetooth LE it advertises "XS speed" plus + * the active slot (the mouse holds three; "XS speed 2" was seen on slot 2, the + * other slots are as the owner described them). The other two models' labels are + * taken from their configurators and have not been seen on hardware. + */ +export const AMKETTE_XS_MODELS: readonly AmketteXsModel[] = [ + { name: "XS Flow 2 Speed", productStrings: ["XS Speed"], bluetoothLabel: "XS speed", lightingScheme: 1, verified: true }, + { name: "XS Flow Plus", productStrings: ["XS Flow+"], bluetoothLabel: null, lightingScheme: 3, verified: false }, + { name: "XS Natural Plus", productStrings: ["XS Natural+"], bluetoothLabel: null, lightingScheme: 4, verified: false }, +]; + +function compactName(name: string): string { + return name.trim().toLowerCase().replace(/[\s_-]+/g, ""); +} + +/** + * The Amkette model a product string names: a USB label or a Bluetooth slot + * name. Retail names are not matched, since the firmware has not been seen to + * report them. + */ +export function amketteXsModel(productName: string | null | undefined): AmketteXsModel | null { + const compact = compactName(productName ?? ""); + if (compact === "") return null; + return AMKETTE_XS_MODELS.find((model) => + model.productStrings.some((label) => compact === compactName(label)) + || amketteBluetoothSlot(productName, model) !== null) ?? null; +} + +/** + * Name shown when the firmware reports no model name. The driver is chosen by + * the usage-0x00 collection, which has only been seen on an Amkette mouse, so an + * unnamed device is shown as Amkette's XS series rather than a specific model. + */ +export const AMKETTE_XS_FALLBACK_NAME = "XS series"; + +/** + * True for the generic strings the 2.4 GHz receiver reports ("2.4G Wireless + * Receiver"), which name the dongle rather than the mouse. + */ +export function amketteIsReceiverName(name: string | null | undefined): boolean { + return /receiver|dongle/i.test(name ?? ""); +} + +/** The Bluetooth slot (1-3) a name like "XS speed 2" advertises for a model, or null. */ +export function amketteBluetoothSlot( + productName: string | null | undefined, + model?: AmketteXsModel, +): number | null { + const compact = compactName(productName ?? ""); + for (const candidate of model ? [model] : AMKETTE_XS_MODELS) { + if (candidate.bluetoothLabel === null) continue; + const base = compactName(candidate.bluetoothLabel); + const slot = /^[123]$/.exec(compact.slice(base.length)); + if (compact.startsWith(base) && slot) return Number(slot[0]); + } + return null; +} diff --git a/src/drivers/amkette/xs-hid.test.ts b/src/drivers/amkette/xs-hid.test.ts new file mode 100644 index 00000000..d45b24a6 --- /dev/null +++ b/src/drivers/amkette/xs-hid.test.ts @@ -0,0 +1,517 @@ +import assert from "node:assert/strict"; +import test, { beforeEach } from "node:test"; + +import { + RYUNIX_COMMAND_REPORT_ID, + RYUNIX_CONFIG_REPORT_ID, + RYUNIX_TELEMETRY_REPORT_ID, + RYUNIX_USAGE, + RYUNIX_USAGE_PAGE, + RYUNIX_VENDOR_ID, + RYUNIX_WIRED_PRODUCT_ID, + RYUNIX_WIRELESS_PRODUCT_ID, +} from "@openmouse/protocol/ryunix"; +import { AMKETTE_XS_BLUETOOTH_PRODUCT_ID, AMKETTE_XS_USAGE } from "@openmouse/protocol/amkette"; +import { AmketteXsHidClient, isAmketteDevice, resetAmketteXsWriteState } from "./xs-hid.ts"; + +beforeEach(() => resetAmketteXsWriteState()); + +function report(reportId: number): HIDReportInfo { + return { reportId, items: [] } as unknown as HIDReportInfo; +} + +function controlCollection(): HIDCollectionInfo { + return { + usagePage: RYUNIX_USAGE_PAGE, + usage: AMKETTE_XS_USAGE, + type: 1, + children: [], + featureReports: [report(RYUNIX_CONFIG_REPORT_ID), report(RYUNIX_COMMAND_REPORT_ID)], + inputReports: [report(RYUNIX_TELEMETRY_REPORT_ID)], + outputReports: [], + } as unknown as HIDCollectionInfo; +} + +interface Sent { + reportId: number; + data: Uint8Array; +} + +/** Telemetry body: active, DPI stage, rate code, battery, charging, LED mode, -, max rate, sensor. */ +const TELEMETRY = [1, 2, 0x02, 73, 0, 0x03, 0, 0x08, 0x20]; + +function fakeDevice(overrides: Partial = {}): { + device: HIDDevice; + sent: Sent[]; + featureReads: number[]; + emit(reportId: number, body: ArrayLike): void; + /** Called after every command with its frame; use it to change what telemetry reports. */ + onCommand(handler: (frame: Uint8Array) => void): void; + setAck(...acks: number[]): void; + listenerAttached(): boolean; +} { + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const sent: Sent[] = []; + const featureReads: number[] = []; + let commandHandler: (frame: Uint8Array) => void = () => {}; + let acks: number[] = []; + let telemetry = Uint8Array.from(TELEMETRY); + + const emit = (reportId: number, body: ArrayLike): void => { + const bytes = Uint8Array.from(body); + const framed = Uint8Array.of(0xaa, 0xbb, ...bytes, 0xcc); + if (reportId === RYUNIX_TELEMETRY_REPORT_ID) telemetry = bytes; + listener?.({ + device, + reportId, + data: new DataView(framed.buffer, 2, bytes.length), + } as HIDInputReportEvent); + }; + + const device = { + vendorId: RYUNIX_VENDOR_ID, + productId: RYUNIX_WIRELESS_PRODUCT_ID, + productName: "", + opened: false, + collections: [controlCollection()], + async open() { this.opened = true; }, + async close() { this.opened = false; }, + addEventListener(_type: string, value: (event: HIDInputReportEvent) => void) { listener = value; }, + removeEventListener(_type: string, value: (event: HIDInputReportEvent) => void) { + if (listener === value) listener = null; + }, + async sendFeatureReport(reportId: number, data: BufferSource) { + const bytes = new Uint8Array(data as ArrayBuffer | ArrayBufferView as ArrayBuffer); + const copy = ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)) + : new Uint8Array(bytes); + sent.push({ reportId, data: copy }); + commandHandler(copy); + }, + async receiveFeatureReport(reportId: number) { + featureReads.push(reportId); + if (reportId === RYUNIX_CONFIG_REPORT_ID) { + queueMicrotask(() => emit(RYUNIX_TELEMETRY_REPORT_ID, telemetry)); + return new DataView(new ArrayBuffer(31)); + } + const ack = acks.length > 0 ? acks.shift()! : 1; + return new DataView(Uint8Array.of(0, ack).buffer); + }, + ...overrides, + } as unknown as HIDDevice; + + return { + device, + sent, + featureReads, + emit, + onCommand(handler) { commandHandler = handler; }, + setAck(...values) { acks = values; }, + listenerAttached: () => listener !== null, + }; +} + +async function openClient(overrides: Partial = {}) { + const transport = fakeDevice(overrides); + const client = new AmketteXsHidClient(transport.device); + await client.open(); + return { transport, client }; +} + +test("support requires an XS product id and the usage 0x00 configuration collection", () => { + assert.equal(AmketteXsHidClient.isSupported(fakeDevice().device), true); + assert.equal(AmketteXsHidClient.isSupported(fakeDevice({ productId: RYUNIX_WIRED_PRODUCT_ID }).device), true); + assert.equal(AmketteXsHidClient.isSupported(fakeDevice({ productId: AMKETTE_XS_BLUETOOTH_PRODUCT_ID }).device), true); + assert.equal(AmketteXsHidClient.isSupported(fakeDevice({ productId: 0x1234 }).device), false); + // The Kyu Pro MX1's usage 0xC7 collection stays with the Ryunix driver, even + // next to a usage 0x00 one. + assert.equal(AmketteXsHidClient.isSupported(fakeDevice({ + collections: [{ ...controlCollection(), usage: RYUNIX_USAGE } as HIDCollectionInfo], + }).device), false); + assert.equal(AmketteXsHidClient.isSupported(fakeDevice({ + collections: [controlCollection(), { ...controlCollection(), usage: RYUNIX_USAGE } as HIDCollectionInfo], + }).device), false); + assert.equal(AmketteXsHidClient.isSupported(fakeDevice({ + collections: [{ ...controlCollection(), usage: 0x01 } as HIDCollectionInfo], + }).device), false); + assert.equal(AmketteXsHidClient.isSupported(fakeDevice({ + collections: [{ ...controlCollection(), featureReports: [] } as unknown as HIDCollectionInfo], + }).device), false); +}); + +test("Amkette is recognised by name only, never guessed", async () => { + // Only the labels the firmware and Amkette's configurators use, not retail names. + assert.equal(isAmketteDevice({ productName: "XS Flow 2 Speed" }), false); + assert.equal(isAmketteDevice({ productName: "XS speed 2" }), true); + assert.equal(isAmketteDevice({ productName: "XS Speed" }), true); + assert.equal(isAmketteDevice({ productName: " xs_flow+ " }), true); + assert.equal(isAmketteDevice({ productName: "XS Natural+" }), true); + assert.equal(isAmketteDevice({ productName: "XS Mouse" }), false); + assert.equal(isAmketteDevice({ productName: "Kyu Pro MX1" }), false); + assert.equal(isAmketteDevice({ productName: "" }), false); + assert.equal(isAmketteDevice({ productName: null }), false); + + const named = await openClient({ productName: "XS Speed" } as Partial); + assert.equal(named.client.deviceBrand(), "Amkette"); + assert.equal((await named.client.readStatus()).brand, "Amkette"); + assert.equal((await named.client.readStatus()).name, "XS Flow 2 Speed"); + + // The usage-0x00 collection chose this driver, so the brand is Amkette even + // without a name; the name only refines the model. + const unnamed = await openClient(); + assert.equal(unnamed.client.deviceBrand(), "Amkette"); + assert.equal((await unnamed.client.readStatus()).brand, "Amkette"); + assert.equal((await unnamed.client.readStatus()).name, "XS series"); + assert.equal((await unnamed.client.readStatus()).ui?.family, "amkette-xs"); + + // The 2.4 GHz receiver's generic string names the dongle, not the mouse. + const dongle = await openClient({ productName: "2.4G Wireless Receiver" } as Partial); + assert.equal(dongle.client.deviceBrand(), "Amkette"); + assert.equal((await dongle.client.readStatus()).name, "XS series"); + + // Any other reported name is kept. + const custom = await openClient({ productName: "Kyu Pro MX1" } as Partial); + assert.equal((await custom.client.readStatus()).name, "Kyu Pro MX1"); +}); + +test("a Bluetooth XS Flow 2 Speed is named, capped at 250 Hz and reads its lighting mode", async () => { + const { transport, client } = await openClient({ + productId: AMKETTE_XS_BLUETOOTH_PRODUCT_ID, + productName: "XS speed 2", + } as Partial); + // Body captured over Bluetooth LE: 250 Hz, battery 100, Breathe, top class 0x31. + transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 2, 0x04, 100, 0, 0x02, 0, 0x31, 0x20]); + const status = await client.readStatus(); + assert.equal(status.brand, "Amkette"); + assert.equal(status.name, "XS Flow 2 Speed"); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "Bluetooth, slot 2"); + assert.equal(status.pollingRateHz, 250); + assert.deepEqual(status.supportedPollingRates, [125, 250]); + assert.equal(status.lighting?.mode, "Breathing single"); + await assert.rejects(() => client.setPollingRate(1000), /does not offer 1000 Hz/); +}); + +test("every Bluetooth slot name identifies the mouse and shows its slot", async () => { + for (const slot of [1, 3]) { + const { client } = await openClient({ + productId: AMKETTE_XS_BLUETOOTH_PRODUCT_ID, + productName: `XS speed ${slot}`, + } as Partial); + const status = await client.readStatus(); + assert.equal(status.name, "XS Flow 2 Speed"); + assert.equal(status.connectionDetail, `Bluetooth, slot ${slot}`); + } + const unnamed = await openClient({ productId: AMKETTE_XS_BLUETOOTH_PRODUCT_ID } as Partial); + assert.equal((await unnamed.client.readStatus()).connectionDetail, "Bluetooth"); +}); + +test("the USB label of an XS Flow 2 Speed shows its retail name", async () => { + const { client } = await openClient({ productName: "XS Speed" } as Partial); + const status = await client.readStatus(); + assert.equal(status.brand, "Amkette"); + assert.equal(status.name, "XS Flow 2 Speed"); + assert.ok(status.lighting, "the Flow 2 Speed has lighting"); +}); + +test("an XS Natural Plus has no lighting card and refuses lighting writes", async () => { + const { transport, client } = await openClient({ productName: "XS Natural+" } as Partial); + const status = await client.readStatus(); + assert.equal(status.name, "XS Natural Plus"); + assert.equal(status.lighting, undefined); + await assert.rejects(() => client.setLighting({ mode: "Static" }), /does not offer/); + assert.equal(transport.sent.length, 0); +}); + +test("opening asks for telemetry and readStatus reports what the mouse sent", async () => { + const { transport, client } = await openClient(); + assert.deepEqual(transport.featureReads, [RYUNIX_CONFIG_REPORT_ID]); + + const status = await client.readStatus(); + assert.equal(status.batteryPercent, 73); + assert.equal(status.batteryState, "Discharging"); + assert.equal(status.pollingRateHz, 500); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.lighting?.mode, "Static"); + assert.equal(status.ui?.settingsReady, true); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000, 2000, 4000, 8000]); + assert.equal(status.ui?.dpiStageEditor?.maxDpi, 8000); + // Nothing has been written, so the vendor's default stages stand in for the unknown table. + assert.deepEqual(status.dpiStages, [400, 800, 1600, 2400, 3200, 6400, 8000]); + assert.equal(status.activeDpiStage, 2); + assert.equal(status.dpi, 1600); + // Not readable from the mouse: the vendor app's defaults stand in. + assert.equal(status.sleepTimeout, 60); + assert.equal(status.liftOffDistance, "High"); + assert.equal(status.motionSync, true); + assert.equal(status.angleSnapping, false); + assert.equal(status.rippleControl, false); + assert.equal(status.lighting?.zone, "Mouse"); + assert.deepEqual(status.buttonMappings?.["Wheel tilt left"], "Tilt left"); + assert.equal(client.getSleepOptions().length, 15); + assert.equal(client.getSleepOptions()[0], 60); + assert.equal(client.getSleepOptions().at(-1), 900); +}); + +test("every status read asks the mouse for a fresh report", async () => { + const { transport, client } = await openClient(); + await client.readStatus(); + // The DPI button on the mouse moved it to stage 5 and the battery dropped; the + // mouse says so only in the next report it is asked for. + const original = transport.device.receiveFeatureReport.bind(transport.device); + transport.device.receiveFeatureReport = async (reportId: number) => { + if (reportId === RYUNIX_CONFIG_REPORT_ID) { + queueMicrotask(() => transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 4, 0x02, 72, 0, 0x03, 0, 0x08, 0x20])); + return new DataView(new ArrayBuffer(31)); + } + return original(reportId); + }; + const status = await client.readStatus(); + assert.equal(status.activeDpiStage, 4); + assert.equal(status.batteryPercent, 72); +}); + +test("the first status read waits for the report opening asked for", async () => { + const { transport, client } = await openClient(); + await client.readStatus(); + assert.equal(transport.featureReads.filter((id) => id === RYUNIX_CONFIG_REPORT_ID).length, 1); + await client.readStatus(); + assert.equal(transport.featureReads.filter((id) => id === RYUNIX_CONFIG_REPORT_ID).length, 2); +}); + +test("startNotifications reports that the mouse does not push status", async () => { + const { client } = await openClient(); + assert.equal(await client.startNotifications(() => {}), false); +}); + +test("a timed-out confirmation does not leave a waiter behind", async () => { + const { transport, client } = await openClient(); + // The mouse acknowledges but keeps reporting 500 Hz, so every confirmation poll times out. + await assert.rejects(() => client.setPollingRate(1000), /still reports 500 Hz/); + for (let index = 0; index < 5; index++) transport.emit(RYUNIX_TELEMETRY_REPORT_ID, TELEMETRY); + assert.equal((client as unknown as { telemetryWaiters: unknown[] }).telemetryWaiters.length, 0); +}); + +test("a wired mouse at full charge reports battery status 2 without losing telemetry", async () => { + const { transport, client } = await openClient(); + transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 2, 0x01, 100, 2, 0, 0, 1, 0x20]); + const status = await client.readStatus(); + assert.equal(status.batteryPercent, 100); + assert.equal(status.batteryState, "Full"); + assert.equal(status.pollingRateHz, 1000); + assert.deepEqual(status.supportedPollingRates, [125, 250, 500, 1000]); + assert.equal(status.lighting?.mode, "Off"); +}); + +test("malformed telemetry is ignored and close detaches", async () => { + const { transport, client } = await openClient(); + assert.equal(transport.listenerAttached(), true); + + transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 1, 0x04, 64, 1, 0x01]); + transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 1, 0xff, 64, 1, 0x01]); + assert.equal((await client.readStatus()).pollingRateHz, 250); + + await client.close(); + assert.equal(transport.listenerAttached(), false); +}); + +test("setPollingRate sends a framed command, reads the acknowledgement and confirms by telemetry", async () => { + const { transport, client } = await openClient(); + transport.onCommand((frame) => { + if (frame[0] === 0x04) transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 2, frame[4]!, 73, 0, 0x03, 0, 0x08, 0x20]); + }); + + assert.equal(await client.setPollingRate(1000), 1000); + const command = transport.sent.at(-1)!; + assert.equal(command.reportId, RYUNIX_COMMAND_REPORT_ID); + assert.equal(command.data.length, 31); + assert.deepEqual([...command.data.subarray(0, 3)], [0x04, 1, 1]); + assert.equal(command.data[4], 0x01); + assert.ok(transport.featureReads.includes(RYUNIX_COMMAND_REPORT_ID), "the acknowledgement is read from report 6"); + assert.equal((await client.readStatus()).pollingRateHz, 1000); +}); + +test("setPollingRate confirms from a status report requested after the write", async () => { + const { transport, client } = await openClient(); + // The mouse applies the rate but only reports it when asked again (feature report 5). + let applied: number | null = null; + transport.onCommand((frame) => { if (frame[0] === 0x04) applied = frame[4]!; }); + const originalRead = transport.device.receiveFeatureReport.bind(transport.device); + transport.device.receiveFeatureReport = async (reportId: number) => { + if (reportId === RYUNIX_CONFIG_REPORT_ID && applied !== null) { + queueMicrotask(() => transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 2, applied!, 73, 0, 0x03, 0, 0x08, 0x20])); + } + return originalRead(reportId); + }; + + assert.equal(await client.setPollingRate(500), 500); + assert.equal((await client.readStatus()).pollingRateHz, 500); +}); + +test("setPollingRate refuses rates the mouse does not offer and rates it fails to apply", async () => { + const limited = await openClient(); + limited.transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 0, 0x02, 50, 0, 0x03, 0, 0x01, 0x20]); + await assert.rejects(() => limited.client.setPollingRate(4000), /does not offer 4000 Hz/); + assert.equal(limited.transport.sent.length, 0); + + const stuck = await openClient(); + // The mouse acknowledges but keeps reporting 500 Hz. + await assert.rejects(() => stuck.client.setPollingRate(1000), /still reports 500 Hz/); +}); + +test("a command is retried and then rejected when the mouse never acknowledges", async () => { + const { transport, client } = await openClient(); + transport.setAck(...new Array(12).fill(0)); + await assert.rejects(() => client.setLiftOffDistance("Low"), /did not acknowledge/); + assert.equal(transport.sent.filter((entry) => entry.data[0] === 0x05).length, 3); +}); + +test("a late acknowledgement is waited for instead of re-sending the command", async () => { + const { transport, client } = await openClient(); + // As seen over the 2.4 GHz receiver: the first reads return 0, then 1. + transport.setAck(0, 0, 1); + assert.equal(await client.setLiftOffDistance("High"), "High"); + assert.equal(transport.sent.filter((entry) => entry.data[0] === 0x05).length, 1); + assert.equal((await client.readStatus()).liftOffDistance, "High"); +}); + +test("the first DPI write seeds a full table and sends X, Y and colours", async () => { + const { transport, client } = await openClient(); + assert.equal(await client.setDpi(1600), 1600); + + const commands = transport.sent.map((entry) => [entry.data[0], entry.data[2]]); + assert.deepEqual(commands, [[0x02, 0x02], [0x02, 0x12], [0x03, 0x01]]); + const x = transport.sent[0]!.data; + assert.equal(x[4], 2, "active stage comes from telemetry"); + assert.equal(x[5], 0b1111111, "seven stages enabled"); + + const status = await client.readStatus(); + assert.equal(status.dpi, 1600); + assert.equal(status.dpiStages?.[2], 1600); + assert.equal(status.activeDpiStage, 2); + assert.equal(status.dpiStages?.length, 7); +}); + +test("DPI is not written until the mouse reports its sensor", async () => { + const { transport, client } = await openClient(); + // A six-byte report carries no sensor type. + transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 2, 0x02, 73, 0, 0x03]); + await assert.rejects(() => client.setDpi(800), /not reported its sensor/); + await assert.rejects(() => client.setDpiStageValue(0, 800), /not reported its sensor/); + await assert.rejects(() => client.setDpiStageCount(3), /not reported its sensor/); + assert.equal(transport.sent.length, 0); +}); + +test("choosing a stage writes the shown table with that stage active, confirmed by status", async () => { + const { transport, client } = await openClient(); + transport.onCommand((frame) => { + if (frame[0] === 0x02 && frame[2] === 0x02) transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, frame[4]!, 0x02, 73, 0, 0x03, 0, 0x08, 0x20]); + }); + // No DPI was written this session: the table shown is the vendor's default. + assert.equal(await client.setActiveDpiStage(0), 0); + const x = transport.sent.find((entry) => entry.data[0] === 0x02 && entry.data[2] === 0x02)!.data; + assert.equal(x[4], 0, "stage 1 active"); + assert.deepEqual([...x.subarray(6, 8)], [3, 0], "stage 1 is 400 DPI (wire value 3)"); + const status = await client.readStatus(); + assert.equal(status.activeDpiStage, 0); + assert.equal(status.dpi, 400); +}); + +test("a stage switch the mouse does not take is reported", async () => { + const { client } = await openClient(); + // Telemetry keeps reporting stage 3 (index 2). + await assert.rejects(() => client.setActiveDpiStage(0), /still reports DPI stage 3/); +}); + +test("DPI stage edits clamp to the sensor and validate the stage", async () => { + const { client } = await openClient(); + await client.setDpi(800); + assert.equal(await client.setDpiStageValue(0, 20000), 8000); + await assert.rejects(() => client.setDpiStageValue(9, 800), RangeError); + await assert.rejects(() => client.setActiveDpiStage(9), RangeError); + assert.equal(await client.setDpiStageCount(3), 3); + const status = await client.readStatus(); + assert.equal(status.dpiStages?.length, 3); + assert.equal(status.dpiStages?.[0], 8000); + await assert.rejects(() => client.setDpiStageCount(8), RangeError); +}); + +test("DPI stage colours are validated and kept", async () => { + const { client } = await openClient(); + await assert.rejects(() => client.setDpiStageColor(0, "red"), RangeError); + assert.equal(await client.setDpiStageColor(1, "#00FF00"), "#00ff00"); + await client.setDpi(800); + assert.equal((await client.readStatus()).dpiStageColors?.[1], "#00ff00"); +}); + +test("lighting maps effects to codes and confirms them by telemetry", async () => { + const { transport, client } = await openClient(); + transport.onCommand((frame) => { + if (frame[0] === 0x06) transport.emit(RYUNIX_TELEMETRY_REPORT_ID, [1, 2, 0x02, 73, 0, frame[5]!, 0, 0x08, 0x20]); + }); + + const status = await client.readStatus(); + assert.deepEqual(status.lighting?.modes, ["Static", "Breathing single", "Off"]); + + await client.setLighting({ mode: "Breathing single", speed: 2 }); + let frame = transport.sent.at(-1)!.data; + assert.deepEqual([...frame.subarray(4, 8)], [0xff, 2, 2, 0]); + assert.equal((await client.readStatus()).lighting?.mode, "Breathing single"); + + await client.setLighting({ mode: "Static", brightness: 50 }); + frame = transport.sent.at(-1)!.data; + assert.deepEqual([...frame.subarray(4, 8)], [0xff, 3, 0x20, 0]); + + // Lighting has no colour of its own: Breathe follows the active DPI stage's colour. + assert.deepEqual((await client.readStatus()).lighting?.colorModes, []); + assert.deepEqual([...transport.sent.at(-1)!.data.subarray(8, 11)], [255, 255, 255]); + + await assert.rejects(() => client.setLighting({ mode: "Wave" }), /does not offer/); + await assert.rejects(() => client.setLighting({ mode: "Static", brightness: 60 }), /not available/); +}); + +test("sleep, processing options and buttons are written and remembered", async () => { + const { transport, client } = await openClient(); + + assert.equal(await client.setSleepTimeout(300), 300); + assert.equal(await client.setSleepTimeout(100000), 900); + assert.equal(await client.setSleepTimeout(1), 60); + const power = transport.sent.filter((entry) => entry.data[0] === 0x07).at(-1)!.data; + assert.deepEqual([...power.subarray(4, 7)], [1, 1, 0]); + + await client.setAngleSnapping(true); + const options = transport.sent.filter((entry) => entry.data[0] === 0x08).at(-1)!.data; + assert.deepEqual([...options.subarray(4, 7)], [1, 1, 0], "unwritten options take the vendor defaults"); + await client.setRippleControl(true); + await client.setMotionSync(false); + const status = await client.readStatus(); + assert.equal(status.angleSnapping, true); + assert.equal(status.motionSync, false); + assert.equal(status.rippleControl, true); + assert.equal(status.sleepTimeout, 60); + + assert.equal(status.buttonMappings?.["DPI button"], "DPI cycle"); + assert.equal(status.buttonMappings?.["Upper centre button"], "Show desktop"); + assert.equal(await client.setButtonMapping("Back button", "Copy"), "Copy"); + const buttons = transport.sent.filter((entry) => entry.data[0] === 0x01); + assert.deepEqual(buttons.map((entry) => entry.data[2]), [0x02, 0x12]); + assert.deepEqual([...buttons[0]!.data.subarray(13, 16)], [0x70, 0x01, 0x06]); + assert.deepEqual([...buttons[0]!.data.subarray(4, 7)], [0x10, 0x01, 0x00], "untouched slots keep the factory layout"); + assert.deepEqual([...buttons[1]!.data.subarray(4, 7)], [0x60, 0x01, 0x00], "the mode switch keeps its function"); + assert.equal((await client.readStatus()).buttonMappings?.["Back button"], "Copy"); + await assert.rejects(() => client.setButtonMapping("Nope", "Copy"), /Unknown button/); + await assert.rejects(() => client.setButtonMapping("Back button", "Nope"), /Unknown button action/); +}); + +test("lift-off only offers the two heights the mouse stores", async () => { + const { client } = await openClient(); + await assert.rejects(() => client.setLiftOffDistance("Medium"), /Low and High/); + assert.deepEqual((await client.readStatus()).supportedLiftOffDistances, ["Low", "High"]); +}); + +test("commands from separate callers are serialised and sequenced", async () => { + const { transport, client } = await openClient(); + await Promise.all([client.setLiftOffDistance("Low"), client.setSleepTimeout(120), client.setAngleSnapping(false)]); + assert.deepEqual(transport.sent.map((entry) => entry.data[1]), [1, 2, 3]); + assert.deepEqual(transport.sent.map((entry) => entry.data[0]), [0x05, 0x07, 0x08]); +}); diff --git a/src/drivers/amkette/xs-hid.ts b/src/drivers/amkette/xs-hid.ts new file mode 100644 index 00000000..17d3de9a --- /dev/null +++ b/src/drivers/amkette/xs-hid.ts @@ -0,0 +1,734 @@ +import type { MouseLighting, MouseLightingMode, MouseStatus } from "../mouse-types.ts"; +import { + decodeKyuProMx1Telemetry, + RYUNIX_BRIGHTNESS_LEVELS, + RYUNIX_BUTTON_ACTIONS, + RYUNIX_BUTTON_FACTORY_CODES, + RYUNIX_BUTTON_POSITIONS, + RYUNIX_BUTTON_SLOTS, + RYUNIX_COMMAND_REPORT_ID, + RYUNIX_CONFIG_REPORT_ID, + RYUNIX_DEFAULT_DPI_COLORS, + RYUNIX_DPI_MAX_STAGES, + RYUNIX_DPI_MIN, + RYUNIX_DPI_STEP, + RYUNIX_LIFT_OFF_MM, + RYUNIX_LIGHTING_SCHEMES, + RYUNIX_SLEEP_MAX_MINUTES, + RYUNIX_SLEEP_MIN_MINUTES, + RYUNIX_SPEED_LEVELS, + RYUNIX_USAGE, + RYUNIX_USAGE_PAGE, + RYUNIX_VENDOR_ID, + RYUNIX_WIRELESS_PRODUCT_ID, + ryunixAckOk, + ryunixButtonActionName, + ryunixClampDpi, + ryunixDefaultDpiStages, + ryunixNextSequence, + ryunixParseColor, + ryunixSensor, + ryunixSetButtonsFrames, + ryunixSetDpiColorsFrame, + ryunixSetDpiFrame, + ryunixSetLiftOffFrame, + ryunixSetLightingFrame, + ryunixSetPollingRateFrame, + ryunixSetPowerFrame, + ryunixSetSensorOptionsFrame, + ryunixSupportedPollingRates, + type KyuProMx1Telemetry, + type RyunixButtonCode, + type RyunixSensor, +} from "@openmouse/protocol/ryunix"; +import { + AMKETTE_XS_BLUETOOTH_PRODUCT_ID, + AMKETTE_XS_FALLBACK_NAME, + AMKETTE_XS_PRODUCT_IDS, + AMKETTE_XS_USAGE, + amketteBluetoothSlot, + amketteIsReceiverName, + amketteXsModel, +} from "@openmouse/protocol/amkette"; + +const COMMAND_ATTEMPTS = 3; +/** + * Waits before each acknowledgement read of one attempt (cumulative 60, 150, 300 + * and 600 ms). Over the 2.4 GHz receiver the acknowledgement was seen reading 0 + * at 60 ms and 1 by 150 ms, so one early read would re-send a command that had + * landed. + */ +const ACK_POLL_DELAYS_MS = [60, 90, 150, 300] as const; +const DPI_AXIS_GAP_MS = 200; +/** How long a status read waits for the report it asked for. */ +const STATUS_WAIT_MS = 300; +/** Fresh-status polls after a write; the mouse reports on request, not on change. */ +const CONFIRM_POLLS = 4; +const CONFIRM_POLL_MS = 400; + +/** Brightness levels 1-4 as the percentages shown in the UI. */ +const BRIGHTNESS_PERCENT = [25, 50, 75, 100] as const; +/** + * The XS Flow 2 Speed's three effects (scheme 1). Other schemes are not offered: + * the XS Natural Plus has none (scheme 4), and the XS Flow Plus's seven + * (scheme 3) have not been mapped or checked, so it keeps this subset. + */ +const BASIC_EFFECT_CODES = RYUNIX_LIGHTING_SCHEMES[1]!; +const LIGHTING_MODE_NAMES: Readonly> = { + 0: "Off", + 2: "Breathing single", + 3: "Static", +}; + +type LiftOffLevel = NonNullable; +const LIFT_OFF_LEVEL_MM: Readonly>> = { Low: 1, High: 2 }; + +/** What the vendor app assumes before anything is written; the mouse cannot confirm them. */ +const VENDOR_DEFAULTS = { + liftOffMm: 2, + sleepMinutes: 1, + angleSnapping: false, + motionSync: true, + rippleControl: false, +} as const; + +/** + * What the mouse cannot report back, as last written by OpenMouse this session. + * Keyed by product ID, so the cable, the receiver and Bluetooth each start + * fresh, and two mice on the same path share it. + */ +interface WriteOnlyState { + dpiStages: number[] | null; + dpiColors: string[]; + /** Null until written; `readStatus` shows the vendor default meanwhile. */ + liftOffMm: number | null; + sleepMinutes: number | null; + angleSnapping: boolean | null; + motionSync: boolean | null; + rippleControl: boolean | null; + /** Positions changed this session; the rest are written as the factory layout. */ + buttons: Array; + brightness: number; + speed: number; +} + +const writeOnlyByProduct = new Map(); + +function stateFor(productId: number): WriteOnlyState { + let state = writeOnlyByProduct.get(productId); + if (!state) { + state = { + dpiStages: null, + dpiColors: [...RYUNIX_DEFAULT_DPI_COLORS], + liftOffMm: null, + sleepMinutes: null, + angleSnapping: null, + motionSync: null, + rippleControl: null, + buttons: new Array(RYUNIX_BUTTON_POSITIONS).fill(null), + brightness: 4, + speed: 3, + }; + writeOnlyByProduct.set(productId, state); + } + return state; +} + +/** Forget what this session wrote, e.g. between tests. */ +export function resetAmketteXsWriteState(productId?: number): void { + if (productId === undefined) writeOnlyByProduct.clear(); + else writeOnlyByProduct.delete(productId); +} + +function hasCollection(collections: readonly HIDCollectionInfo[], usage: number, featureReport?: number): boolean { + return collections.some((collection) => + collection.usagePage === RYUNIX_USAGE_PAGE + && collection.usage === usage + && (featureReport === undefined || collection.featureReports.some((report) => report.reportId === featureReport)) + || hasCollection(collection.children, usage, featureReport)); +} + +/** True when the product string names an Amkette XS model; never inferred from IDs. */ +export function isAmketteDevice(device: { productName?: string | null }): boolean { + return amketteXsModel(device.productName) !== null; +} + +/** 0 discharging, 1 charging; a wired mouse at 100% sends 2, read as full. */ +function batteryState(telemetry: KyuProMx1Telemetry | null): MouseStatus["batteryState"] { + if (!telemetry) return "Unknown"; + switch (telemetry.batteryStatusCode) { + case 0: return "Discharging"; + case 1: return "Charging"; + case 2: return telemetry.batteryPercent === 100 ? "Full" : "Unknown"; + default: return "Unknown"; + } +} + +/** How a write is judged against the status the mouse reports afterwards. */ +interface Confirmation { + accept: (telemetry: KyuProMx1Telemetry) => boolean; + mismatch: (telemetry: KyuProMx1Telemetry) => string; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function bytesOf(view: DataView): Uint8Array { + return new Uint8Array(view.buffer, view.byteOffset, view.byteLength); +} + +/** + * The Amkette XS firmware on 0x04F3, which shares its IDs and wire protocol with + * the Ryunix Kyu Pro MX1 (see `docs/amkette-xs-flow-testing.md`). It is told + * apart by its configuration collection on usage 0x00, seen on all three paths + * of an XS Flow 2 Speed; the Kyu Pro MX1 (usage 0xC7) keeps its own read-only + * driver. + */ +export class AmketteXsHidClient { + private telemetry: KyuProMx1Telemetry | null = null; + /** Counts decoded status reports, so a read can wait for one it asked for. */ + private telemetryCount = 0; + /** `open()` has asked for a report that no status read has waited for yet. */ + private openRequestPending = false; + private listening = false; + private sequence = 0; + private queue: Promise = Promise.resolve(); + private telemetryWaiters: Array<() => void> = []; + private onChange: (() => void) | null = null; + + public constructor(public readonly device: HIDDevice) {} + + public static isSupported(device: HIDDevice): boolean { + return device.vendorId === RYUNIX_VENDOR_ID + && AMKETTE_XS_PRODUCT_IDS.has(device.productId) + && hasCollection(device.collections, AMKETTE_XS_USAGE, RYUNIX_CONFIG_REPORT_ID) + && !hasCollection(device.collections, RYUNIX_USAGE); + } + + private readonly onInputReport = (event: HIDInputReportEvent): void => { + const bytes = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + const telemetry = decodeKyuProMx1Telemetry(bytes, event.reportId); + if (!telemetry) return; + this.telemetry = telemetry; + this.telemetryCount += 1; + const waiters = this.telemetryWaiters; + this.telemetryWaiters = []; + for (const wake of waiters) wake(); + this.onChange?.(); + }; + + public async open(): Promise { + if (!AmketteXsHidClient.isSupported(this.device)) { + throw new Error("The mouse's configuration collection is unavailable."); + } + if (!this.device.opened) await this.device.open(); + if (!this.listening) { + this.device.addEventListener("inputreport", this.onInputReport); + this.listening = true; + this.openRequestPending = true; + await this.requestTelemetry(); + } + } + + public async close(): Promise { + if (this.listening) { + this.device.removeEventListener("inputreport", this.onInputReport); + this.listening = false; + } + this.telemetry = null; + this.openRequestPending = false; + this.onChange = null; + if (this.device.opened) await this.device.close(); + } + + /** + * The mouse sends status only when asked (feature report 0x05), not when it + * changes, so there is nothing to subscribe to: callers keep polling + * `readStatus`, which asks for a fresh report each time. The callback still + * fires on any report that does arrive. + */ + public async startNotifications(onChange?: () => void): Promise { + await this.open(); + this.onChange = onChange ?? null; + return false; + } + + /** + * Always Amkette: the driver is chosen by the usage-0x00 collection, which has + * only been seen on an Amkette mouse, so the brand follows the same evidence. + * The product name only refines the model. + */ + public deviceBrand(): "Amkette" { + return "Amkette"; + } + + public displayName(): string { + const model = amketteXsModel(this.device.productName); + if (model) return model.name; + // A receiver's generic string names the dongle, not the mouse, so it is not shown. + const name = this.device.productName?.trim(); + return name && !amketteIsReceiverName(name) ? name : AMKETTE_XS_FALLBACK_NAME; + } + + public isBluetooth(): boolean { + return this.device.productId === AMKETTE_XS_BLUETOOTH_PRODUCT_ID; + } + + public isWireless(): boolean { + return this.device.productId === RYUNIX_WIRELESS_PRODUCT_ID || this.isBluetooth(); + } + + /** Bluetooth shows its slot when the advertised name carries one. */ + private connectionDetail(): string { + if (this.isBluetooth()) { + const slot = amketteBluetoothSlot(this.device.productName); + return slot === null ? "Bluetooth" : `Bluetooth, slot ${slot}`; + } + return this.isWireless() ? "2.4 GHz receiver" : "USB"; + } + + public getDpiOptions(): number[] { + return []; + } + + /** Auto-sleep choices in seconds: whole minutes from 1 to 15. */ + public getSleepOptions(): number[] { + const options: number[] = []; + for (let minutes = RYUNIX_SLEEP_MIN_MINUTES; minutes <= RYUNIX_SLEEP_MAX_MINUTES; minutes++) options.push(minutes * 60); + return options; + } + + public getSupportedPollingRates(): number[] { + return ryunixSupportedPollingRates(this.telemetry?.maxPollingCode ?? null); + } + + /** Effects the detected model offers; an unidentified mouse gets the basic set. */ + private lightingEffects(): readonly number[] { + return amketteXsModel(this.device.productName)?.lightingScheme === 4 ? [] : BASIC_EFFECT_CODES; + } + + /** Reading feature report 0x05 makes the mouse emit an input report 0x04. */ + private async requestTelemetry(): Promise { + try { + await this.device.receiveFeatureReport(RYUNIX_CONFIG_REPORT_ID); + } catch { + // A failed read leaves the last report in place; the next read asks again. + } + } + + private waitForTelemetry( + accept: (telemetry: KyuProMx1Telemetry) => boolean, + timeoutMs: number, + ): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (value: KyuProMx1Telemetry | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const check = (): void => { + if (settled) return; + if (this.telemetry && accept(this.telemetry)) finish(this.telemetry); + else this.telemetryWaiters.push(check); + }; + const timer = setTimeout(() => finish(null), timeoutMs); + check(); + }); + } + + /** + * Ask for a status report and wait briefly for it, keeping the last one if + * none comes. Right after `open()` the report it asked for is awaited instead + * of asking again. + */ + private async refreshTelemetry(): Promise { + if (this.openRequestPending) { + this.openRequestPending = false; + await this.waitForTelemetry(() => this.telemetryCount > 0, STATUS_WAIT_MS); + return; + } + const before = this.telemetryCount; + await this.requestTelemetry(); + await this.waitForTelemetry(() => this.telemetryCount > before, STATUS_WAIT_MS); + } + + /** + * The mouse only sends status when asked, so a write is judged against a + * report requested after it. Returns the matching report, or null if none of + * the polls matched. + */ + private async confirmByTelemetry( + accept: (telemetry: KyuProMx1Telemetry) => boolean, + ): Promise { + for (let poll = 0; poll < CONFIRM_POLLS; poll++) { + const before = this.telemetryCount; + await this.requestTelemetry(); + const match = await this.waitForTelemetry( + (telemetry) => this.telemetryCount > before && accept(telemetry), + CONFIRM_POLL_MS, + ); + if (match) return match; + } + return null; + } + + private enqueue(action: () => Promise): Promise { + const run = this.queue.then(action); + this.queue = run.catch(() => undefined); + return run; + } + + private nextSequence(): number { + this.sequence = ryunixNextSequence(this.sequence); + return this.sequence; + } + + /** Send one frame on report 0x06 and require the mouse to acknowledge it. */ + private async sendFrame(frame: Uint8Array): Promise { + let lastError: unknown = null; + for (let attempt = 0; attempt < COMMAND_ATTEMPTS; attempt++) { + await delay(100 + attempt * 50); + try { + await this.device.sendFeatureReport(RYUNIX_COMMAND_REPORT_ID, new Uint8Array(frame)); + for (const wait of ACK_POLL_DELAYS_MS) { + await delay(wait); + const ack = await this.device.receiveFeatureReport(RYUNIX_COMMAND_REPORT_ID); + if (ryunixAckOk(bytesOf(ack))) return; + } + lastError = new Error("The mouse did not acknowledge the command."); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error ? lastError : new Error("The mouse did not acknowledge the command."); + } + + /** + * Send frames and, when `accept` is given, judge the write against fresh + * status, all inside the command queue so another setter's frames cannot land + * between the write and its confirmation. Throws when the mouse reports a + * value other than the one written; a mouse that sends no status at all is + * judged by its acknowledgement alone. + */ + private send( + build: (sequence: () => number) => Uint8Array | Uint8Array[], + confirm?: Confirmation, + ): Promise { + return this.enqueue(async () => { + await this.open(); + const frames = build(() => this.nextSequence()); + for (const [index, frame] of (Array.isArray(frames) ? frames : [frames]).entries()) { + if (index > 0) await delay(DPI_AXIS_GAP_MS); + await this.sendFrame(frame); + } + if (!confirm) return; + const confirmed = await this.confirmByTelemetry(confirm.accept); + if (!confirmed && this.telemetry && !confirm.accept(this.telemetry)) { + throw new Error(confirm.mismatch(this.telemetry)); + } + }); + } + + /** + * DPI values are encoded per sensor (50 or 100 counts per step), so a write + * waits for the mouse to report which sensor it has rather than assume one. + */ + private requireSensor(): RyunixSensor { + const sensorType = this.telemetry?.sensorType ?? null; + if (sensorType === null) { + throw new Error("The mouse has not reported its sensor yet, so DPI cannot be written safely."); + } + return ryunixSensor(sensorType); + } + + /** The vendor app seeds a table the first time, since the mouse never reports one. */ + private tableFor(state: WriteOnlyState): number[] { + return state.dpiStages ? [...state.dpiStages] : ryunixDefaultDpiStages(ryunixSensor(this.telemetry?.sensorType ?? null)); + } + + private async writeDpiTable( + stages: number[], + activeStage: number, + confirm?: Confirmation, + ): Promise { + const sensor = this.requireSensor(); + const state = stateFor(this.device.productId); + const table = { stages, activeStage }; + await this.send((sequence) => [ + ryunixSetDpiFrame(sequence(), table, sensor, "x"), + ryunixSetDpiFrame(sequence(), table, sensor, "y"), + ryunixSetDpiColorsFrame(sequence(), state.dpiColors), + ], confirm); + state.dpiStages = [...stages]; + } + + private activeStage(count: number): number { + const reported = this.telemetry?.dpiStage ?? 0; + return reported < count ? reported : 0; + } + + public readStatus(): Promise { + return this.enqueue(async () => { + await this.open(); + await this.refreshTelemetry(); + return this.buildStatus(); + }); + } + + private buildStatus(): MouseStatus { + const telemetry = this.telemetry; + const state = stateFor(this.device.productId); + const sensor = ryunixSensor(telemetry?.sensorType ?? null); + const wireless = this.isWireless(); + // Until a table is written the mouse's own stages are unknown; the vendor's + // defaults are shown so the editor has rows, and the note below says so. + const stages = this.tableFor(state); + const activeStage = this.activeStage(stages.length); + + const effects = this.lightingEffects(); + const mode = telemetry ? LIGHTING_MODE_NAMES[telemetry.ledModeCode] ?? null : null; + const lighting: MouseLighting | undefined = telemetry && effects.length > 0 ? { + zone: "Mouse", + modes: effects.map((code) => LIGHTING_MODE_NAMES[code]!), + mode, + // No effect has its own colour: Breathe takes the active DPI stage's colour. + color: null, + color2: null, + colorModes: [], + dualColorModes: [], + reactiveModes: ["Breathing single"], + speeds: [...RYUNIX_SPEED_LEVELS], + speed: state.speed, + brightness: BRIGHTNESS_PERCENT[state.brightness - 1] ?? 100, + brightnessLevels: [...BRIGHTNESS_PERCENT], + } : undefined; + + const buttonMappings: Record = {}; + for (const [index, slot] of RYUNIX_BUTTON_SLOTS.entries()) { + const code = state.buttons[index]; + buttonMappings[slot.name] = (code ? ryunixButtonActionName(code) : null) ?? slot.factory; + } + + const liftOffMm = state.liftOffMm ?? VENDOR_DEFAULTS.liftOffMm; + const liftOff = (Object.entries(LIFT_OFF_LEVEL_MM).find(([, mm]) => mm === liftOffMm)?.[0] as LiftOffLevel | undefined) ?? null; + + return { + brand: this.deviceBrand(), + name: this.displayName(), + ui: { + family: "amkette-xs", + settingsReady: true, + // Polling and the active stage are read, but DPI values never are. + valuesVerified: false, + hideUnsupportedPollingRates: true, + hideSignalCard: true, + forceShowBattery: true, + showAdvancedSection: true, + defaultDisplayName: AMKETTE_XS_FALLBACK_NAME, + statusNote: "This mouse reports battery, active DPI stage, polling rate and lighting mode. DPI values, lift-off, sleep, processing options and buttons cannot be read back: they show what OpenMouse last wrote, and the vendor app's defaults until then. Choosing a stage or changing a DPI value writes the whole stage table as shown here, replacing stages stored on the mouse by another tool.", + dpiStageEditor: { + maxStages: RYUNIX_DPI_MAX_STAGES, + countEditable: true, + minDpi: RYUNIX_DPI_MIN, + maxDpi: sensor.maxDpi, + stepDpi: RYUNIX_DPI_STEP, + }, + }, + batteryPercent: telemetry?.batteryPercent ?? null, + batteryState: batteryState(telemetry), + dpi: stages[activeStage] ?? 0, + dpiStages: [...stages], + activeDpiStage: activeStage, + dpiStageColors: state.dpiColors.slice(0, stages.length), + pollingRateHz: telemetry?.pollingRateHz ?? 0, + supportedPollingRates: this.getSupportedPollingRates(), + activeProfile: null, + connectionType: wireless ? "Wireless" : "Wired", + connectionDetail: this.connectionDetail(), + liftOffDistance: liftOff, + supportedLiftOffDistances: ["Low", "High"], + sleepTimeout: (state.sleepMinutes ?? VENDOR_DEFAULTS.sleepMinutes) * 60, + angleSnapping: state.angleSnapping ?? VENDOR_DEFAULTS.angleSnapping, + motionSync: state.motionSync ?? VENDOR_DEFAULTS.motionSync, + rippleControl: state.rippleControl ?? VENDOR_DEFAULTS.rippleControl, + buttonMappings, + buttonOptions: [...RYUNIX_BUTTON_ACTIONS.keys()], + ...(lighting ? { lighting } : {}), + firmware: [], + }; + } + + public async setPollingRate(pollingRateHz: number): Promise { + if (!this.getSupportedPollingRates().includes(pollingRateHz)) { + throw new Error(`This mouse does not offer ${pollingRateHz} Hz.`); + } + await this.send((sequence) => ryunixSetPollingRateFrame(sequence(), pollingRateHz), { + accept: (value) => value.pollingRateHz === pollingRateHz, + mismatch: (value) => `The mouse still reports ${value.pollingRateHz} Hz after the change.`, + }); + return pollingRateHz; + } + + public async setDpi(dpi: number): Promise { + const stages = this.tableFor(stateFor(this.device.productId)); + return this.writeStageValue(stages, this.activeStage(stages.length), dpi); + } + + public async setDpiStageValue(stage: number, dpi: number): Promise { + return this.writeStageValue(this.tableFor(stateFor(this.device.productId)), stage, dpi); + } + + private async writeStageValue(stages: number[], stage: number, dpi: number): Promise { + if (!Number.isInteger(stage) || stage < 0 || stage >= stages.length) { + throw new RangeError(`Invalid DPI stage ${stage + 1}.`); + } + const value = ryunixClampDpi(dpi, this.requireSensor()); + stages[stage] = value; + await this.writeDpiTable(stages, this.activeStage(stages.length)); + return value; + } + + /** + * The mouse selects a stage only through a full table write, so this writes + * the table OpenMouse shows (the vendor's defaults until something else has + * been written this session) with the chosen stage active, as Amkette's own + * configurator does on every stage click. Stages stored on the mouse by + * another tool are replaced by the shown values. + */ + public async setActiveDpiStage(stage: number): Promise { + const stages = this.tableFor(stateFor(this.device.productId)); + if (!Number.isInteger(stage) || stage < 0 || stage >= stages.length) { + throw new RangeError(`Invalid DPI stage ${stage + 1}.`); + } + await this.writeDpiTable(stages, stage, { + accept: (value) => value.dpiStage === stage, + mismatch: (value) => `The mouse still reports DPI stage ${value.dpiStage + 1} after the change.`, + }); + return stage; + } + + public async setDpiStageCount(count: number): Promise { + if (!Number.isInteger(count) || count < 1 || count > RYUNIX_DPI_MAX_STAGES) { + throw new RangeError(`A DPI table needs 1 to ${RYUNIX_DPI_MAX_STAGES} stages.`); + } + const sensor = this.requireSensor(); + const current = this.tableFor(stateFor(this.device.productId)); + const stages = ryunixDefaultDpiStages(sensor, RYUNIX_DPI_MAX_STAGES) + .map((fallback, index) => current[index] ?? fallback) + .slice(0, count); + await this.writeDpiTable(stages, this.activeStage(stages.length)); + return count; + } + + public async setDpiStageColor(index: number, color: string): Promise { + ryunixParseColor(color); + const state = stateFor(this.device.productId); + if (!Number.isInteger(index) || index < 0 || index >= RYUNIX_DPI_MAX_STAGES) { + throw new RangeError(`Invalid DPI stage ${index + 1}.`); + } + const colors = [...state.dpiColors]; + colors[index] = color.toLowerCase(); + await this.send((sequence) => ryunixSetDpiColorsFrame(sequence(), colors)); + state.dpiColors = colors; + return colors[index]!; + } + + public async setLiftOffDistance(level: LiftOffLevel): Promise { + const millimetres = LIFT_OFF_LEVEL_MM[level]; + if (millimetres === undefined || !(RYUNIX_LIFT_OFF_MM as readonly number[]).includes(millimetres)) { + throw new Error(`This mouse offers Low and High lift-off, not ${level}.`); + } + await this.send((sequence) => ryunixSetLiftOffFrame(sequence(), millimetres)); + stateFor(this.device.productId).liftOffMm = millimetres; + return level; + } + + /** Auto sleep in seconds; the mouse stores whole minutes from 1 to 15. */ + public async setSleepTimeout(seconds: number): Promise { + const minutes = Math.min( + RYUNIX_SLEEP_MAX_MINUTES, + Math.max(RYUNIX_SLEEP_MIN_MINUTES, Math.round(seconds / 60)), + ); + // The vendor app also sends its wake-on-move and light-on-move defaults. + await this.send((sequence) => ryunixSetPowerFrame(sequence(), { + sleepMinutes: minutes, + wakeOnMove: true, + lightOnMove: false, + })); + stateFor(this.device.productId).sleepMinutes = minutes; + return minutes * 60; + } + + private async writeSensorOptions(patch: Partial>): Promise { + const state = stateFor(this.device.productId); + // The mouse cannot report these, so unwritten ones take the vendor app's defaults. + const next = { + angleSnapping: patch.angleSnapping ?? state.angleSnapping ?? VENDOR_DEFAULTS.angleSnapping, + motionSync: patch.motionSync ?? state.motionSync ?? VENDOR_DEFAULTS.motionSync, + rippleControl: patch.rippleControl ?? state.rippleControl ?? VENDOR_DEFAULTS.rippleControl, + }; + await this.send((sequence) => ryunixSetSensorOptionsFrame(sequence(), next)); + Object.assign(state, next); + } + + public async setAngleSnapping(enabled: boolean): Promise { + await this.writeSensorOptions({ angleSnapping: enabled }); + return enabled; + } + + public async setMotionSync(enabled: boolean): Promise { + await this.writeSensorOptions({ motionSync: enabled }); + return enabled; + } + + public async setRippleControl(enabled: boolean): Promise { + await this.writeSensorOptions({ rippleControl: enabled }); + return enabled; + } + + public async setLighting(lighting: Pick & Partial>): Promise { + const effect = this.lightingEffects().find((code) => LIGHTING_MODE_NAMES[code] === lighting.mode); + if (effect === undefined || !lighting.mode) { + throw new Error(`This mouse does not offer the ${lighting.mode ?? "selected"} lighting effect.`); + } + const state = stateFor(this.device.productId); + const level = lighting.brightness == null + ? state.brightness + : RYUNIX_BRIGHTNESS_LEVELS[BRIGHTNESS_PERCENT.indexOf(lighting.brightness as 25 | 50 | 75 | 100)]; + if (level === undefined) throw new Error(`Brightness ${lighting.brightness}% is not available.`); + const speed = lighting.speed ?? state.speed; + if (!(RYUNIX_SPEED_LEVELS as readonly number[]).includes(speed)) { + throw new Error(`Lighting speed ${speed} is not available.`); + } + await this.send((sequence) => ryunixSetLightingFrame(sequence(), { effect, brightness: level, speed }), { + accept: (value) => value.ledModeCode === effect, + mismatch: () => "The mouse still reports its previous lighting effect after the change.", + }); + state.brightness = level; + state.speed = speed; + return lighting.mode; + } + + /** + * `button` is the slot name from `buttonMappings`; `action` one of + * `buttonOptions`. Every write carries all ten positions; positions not + * changed in this session are written as the vendor app's default layout, as + * the vendor app does, rather than 0xFF, whose meaning here is unknown. + */ + public async setButtonMapping(button: string, action: string): Promise { + const index = RYUNIX_BUTTON_SLOTS.findIndex((slot) => slot.name === button); + if (index < 0) throw new Error(`Unknown button "${button}".`); + const code = RYUNIX_BUTTON_ACTIONS.get(action); + if (!code) throw new Error(`Unknown button action "${action}".`); + const state = stateFor(this.device.productId); + const changed = [...state.buttons]; + changed[index] = code; + const positions = RYUNIX_BUTTON_FACTORY_CODES.map((factory, position) => changed[position] ?? factory); + await this.send((sequence) => ryunixSetButtonsFrames([sequence(), sequence()], positions)); + state.buttons = changed; + return action; + } +} diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ffcc6d06..3968f7df 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"; + 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" | "Amkette" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ"; name: string; /** Optional LunaFury controls, populated only after successful device reads. */ lunafury?: import("../lamzu/lunafury.js").LunaFurySettings; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index e7384d0f..cef95190 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -88,6 +88,7 @@ import { IncottHidClient } from "./incott/hid.ts"; import { HyperXHidClient } from "./hyperx/hid.ts"; import { MchoseV3HidClient } from "./mchose/v3-hid.ts"; import { KyuProMx1Client } from "./ryunix/kyu-pro-mx1-hid.ts"; +import { AmketteXsHidClient } from "./amkette/xs-hid.ts"; import { BytechHidClient } from "./bytech/hid.ts"; import { RapooHidClient } from "./rapoo/hid.ts"; import { CoolerMasterHidClient } from "./coolermaster/hid.ts"; @@ -95,7 +96,7 @@ import { AjazzHidClient } from "./ajazz/hid.ts"; import { AjazzAk820HidClient } from "./ajazz/ak820-hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRivalProHidClient | SteelSeriesSensei310HidClient | SteelSeriesPrimeMiniHidClient | SteelSeriesRival5HidClient | SteelSeriesRival3Gen2HidClient | SteelSeriesRival3WirelessV2HidClient | SteelSeriesAerox3WirelessGen2HidClient | SteelSeriesRival600HidClient | SteelSeriesRival100HidClient | SteelSeriesRival110HidClient | SteelSeriesRival300HidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | SteelSeriesSenseiRivalProHidClient | SteelSeriesSensei310HidClient | SteelSeriesPrimeMiniHidClient | SteelSeriesRival5HidClient | SteelSeriesRival3Gen2HidClient | SteelSeriesRival3WirelessV2HidClient | SteelSeriesAerox3WirelessGen2HidClient | SteelSeriesRival600HidClient | SteelSeriesRival100HidClient | SteelSeriesRival110HidClient | SteelSeriesRival300HidClient | SteelSeriesSenseiRawHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | AmketteXsHidClient | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient; export interface DeviceDriver { brand: string; supports(device: HIDDevice): boolean; @@ -219,6 +220,9 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Incott", supports: (device) => IncottHidClient.isSupported(device), create: (device) => new IncottHidClient(device), score: () => 8 }, { brand: "HyperX", supports: (device) => HyperXHidClient.isSupported(device), create: (device) => new HyperXHidClient(device), score: () => 5 }, { brand: "Ryunix", supports: (device) => KyuProMx1Client.isSupported(device), create: (device) => new KyuProMx1Client(device), score: () => 7 }, + // Shares VID 0x04F3 and both Ryunix product IDs; disjoint by the configuration + // collection's usage (0x00 here, 0xC7 for the Kyu Pro MX1). + { brand: "Amkette", supports: (device) => AmketteXsHidClient.isSupported(device), create: (device) => new AmketteXsHidClient(device), score: () => 7 }, // Rapoo owns vendor id 0x24AE and the 0xFF00:0x000E configuration // collection, neither of which another driver claims, so the two product ids // can be the whole matcher. @@ -245,6 +249,7 @@ export function deviceBrand(client: SupportedClient): string { if (client instanceof AtkHidClient) return client.deviceBrand(); if (client instanceof DeluxHidClient) return client.deviceBrand(); if (client instanceof AttackSharkHidClient) return client.deviceBrand(); + if (client instanceof AmketteXsHidClient) return client.deviceBrand(); if (client instanceof KsnakeHidClient && isNoirM2NexDevice(client.device)) return "Noir Gear"; return driverFor(client.device)?.brand ?? "Unknown"; } diff --git a/src/drivers/ryunix/kyu-pro-mx1-hid.test.ts b/src/drivers/ryunix/kyu-pro-mx1-hid.test.ts index 5893e483..d6bb0165 100644 --- a/src/drivers/ryunix/kyu-pro-mx1-hid.test.ts +++ b/src/drivers/ryunix/kyu-pro-mx1-hid.test.ts @@ -97,6 +97,17 @@ test("Kyu Pro MX1 exposes observed telemetry without advertising writes", async assert.equal("setPollingRate" in client, false); }); +test("Kyu Pro MX1 reports battery status 2 as full, not discharging", async () => { + const transport = fakeDevice(); + const client = new KyuProMx1Client(transport.device); + await client.open(); + + transport.emit(RYUNIX_TELEMETRY_REPORT_ID, Uint8Array.of(1, 2, 0x01, 100, 2, 0x03)); + assert.equal((await client.readStatus()).batteryState, "Full"); + transport.emit(RYUNIX_TELEMETRY_REPORT_ID, Uint8Array.of(1, 2, 0x01, 90, 2, 0x03)); + assert.equal((await client.readStatus()).batteryState, "Unknown"); +}); + test("Kyu Pro MX1 ignores malformed telemetry and detaches on close", async () => { const transport = fakeDevice(); const client = new KyuProMx1Client(transport.device); diff --git a/src/drivers/ryunix/kyu-pro-mx1-hid.ts b/src/drivers/ryunix/kyu-pro-mx1-hid.ts index 238d741a..c27c4a34 100644 --- a/src/drivers/ryunix/kyu-pro-mx1-hid.ts +++ b/src/drivers/ryunix/kyu-pro-mx1-hid.ts @@ -88,7 +88,10 @@ export class KyuProMx1Client { }, batteryPercent: telemetry?.batteryPercent ?? null, batteryState: telemetry - ? telemetry.charging ? "Charging" : "Discharging" + // Status 2 is the cable at full charge; the decoder used to drop it. + ? telemetry.batteryStatusCode === 2 + ? telemetry.batteryPercent === 100 ? "Full" : "Unknown" + : telemetry.charging ? "Charging" : "Discharging" : "Unknown", dpi: 0, pollingRateHz: telemetry?.pollingRateHz ?? 0, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 3387e201..c1c20047 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -109,6 +109,7 @@ import { RYUNIX_USAGE_PAGE, RYUNIX_VENDOR_ID, } from "@openmouse/protocol/ryunix"; +import { AMKETTE_XS_PRODUCT_IDS, AMKETTE_XS_USAGE } from "@openmouse/protocol/amkette"; import { RAPOO_CONFIG_USAGE, RAPOO_CONFIG_USAGE_PAGE, @@ -152,6 +153,9 @@ export const VENDOR_ID = { vaxee: VAXEE_VENDOR_ID, asus: ASUS_VENDOR_ID, ryunix: RYUNIX_VENDOR_ID, + // Amkette's XS series shares the 0x04F3 OEM firmware and IDs with Ryunix; the + // two drivers are kept apart by the configuration collection's usage. + amkette: RYUNIX_VENDOR_ID, rapoo: RAPOO_VENDOR_ID, motospeed: MOTOSPEED_VENDOR_ID, pulsar: 0x3710, @@ -848,6 +852,14 @@ export const RYUNIX_HID_FILTERS: HIDDeviceFilter[] = [...RYUNIX_PRODUCT_IDS].map usagePage: RYUNIX_USAGE_PAGE, usage: RYUNIX_USAGE, })); +// Same product IDs as Ryunix plus Bluetooth (0x026D); the XS firmware publishes +// its configuration collection on usage 0x00 instead of 0xC7. +export const AMKETTE_XS_HID_FILTERS: HIDDeviceFilter[] = [...AMKETTE_XS_PRODUCT_IDS].map((productId) => ({ + vendorId: RYUNIX_VENDOR_ID, + productId, + usagePage: RYUNIX_USAGE_PAGE, + usage: AMKETTE_XS_USAGE, +})); // Both the 2.4 GHz receiver and the mouse on its cable answer on the same // usage page and usage, so one filter per product id covers the family. @@ -993,6 +1005,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...INCOTT_HID_FILTERS, ...HYPERX_HID_FILTERS, ...RYUNIX_HID_FILTERS, + ...AMKETTE_XS_HID_FILTERS, ...RAPOO_HID_FILTERS, { vendorId: VENDOR_ID.coolermaster, usagePage: COOLERMASTER_USAGE_PAGE, usage: COOLERMASTER_USAGE }, { vendorId: VENDOR_ID.ajazz, productId: AJAZZ_AK820_WIRED_PID, usagePage: 0xff00 }, diff --git a/src/index.ts b/src/index.ts index 869b64cb..531df911 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export * as valkyrie from "./valkyrie/index.js"; export * as vaxee from "./vaxee/index.js"; export * as incott from "./incott/index.js"; export * as ryunix from "./ryunix/index.js"; +export * as amkette from "./amkette/index.js"; export * as redragon from "./redragon/index.js"; export * as delux from "./delux/index.js"; export * as bytech from "./bytech/index.js"; diff --git a/src/ryunix/index.ts b/src/ryunix/index.ts index 6c9e5041..ea9ecf93 100644 --- a/src/ryunix/index.ts +++ b/src/ryunix/index.ts @@ -1 +1,2 @@ export * from "./kyu-pro-mx1.js"; +export * from "./protocol.js"; diff --git a/src/ryunix/kyu-pro-mx1.test.ts b/src/ryunix/kyu-pro-mx1.test.ts index e0dcedb7..91c77b77 100644 --- a/src/ryunix/kyu-pro-mx1.test.ts +++ b/src/ryunix/kyu-pro-mx1.test.ts @@ -18,11 +18,25 @@ test("Kyu Pro MX1 telemetry decodes observed status fields", () => { pollingRateHz: 250, batteryPercent: 84, charging: true, + batteryStatusCode: 1, ledMode: "Breathing single", ledModeCode: 0x02, + maxPollingCode: null, + sensorType: null, }); }); +test("Kyu Pro MX1 telemetry reads the max rate, sensor type and high polling rates", () => { + const status = decodeKyuProMx1Telemetry( + Uint8Array.of(1, 0, 0x12, 90, 0, 0x03, 0, 0x04, 0x95), + RYUNIX_TELEMETRY_REPORT_ID, + ); + + assert.equal(status?.pollingRateHz, 4000); + assert.equal(status?.maxPollingCode, 4); + assert.equal(status?.sensorType, 0x95); +}); + test("Kyu Pro MX1 telemetry preserves an unknown LED mode without guessing", () => { const status = decodeKyuProMx1Telemetry( Uint8Array.of(0, 1, 0x01, 50, 0, 0xfe), @@ -38,4 +52,24 @@ test("Kyu Pro MX1 telemetry rejects malformed or unrelated reports", () => { assert.equal(decodeKyuProMx1Telemetry(Uint8Array.of(1, 2, 3, 50, 0, 1), RYUNIX_TELEMETRY_REPORT_ID), null); assert.equal(decodeKyuProMx1Telemetry(Uint8Array.of(1, 2, 4, 101, 0, 1), RYUNIX_TELEMETRY_REPORT_ID), null); assert.equal(decodeKyuProMx1Telemetry(Uint8Array.of(1, 2, 4, 50, 0, 1), 0x01), null); + // Battery status is 0, 1 or 2 (full on USB power); anything else is not telemetry. + assert.equal(decodeKyuProMx1Telemetry(Uint8Array.of(1, 2, 4, 50, 3, 1), RYUNIX_TELEMETRY_REPORT_ID), null); +}); + +test("telemetry decodes a report captured from a wired XS Flow 2 Speed", () => { + // Input report 0x04 body as read over USB at 100% battery. + const status = decodeKyuProMx1Telemetry( + Uint8Array.of(0x01, 0x02, 0x01, 0x64, 0x02, 0x00, 0x00, 0x01, 0x20), + RYUNIX_TELEMETRY_REPORT_ID, + ); + + assert.equal(status?.pollingRateHz, 1000); + assert.equal(status?.batteryPercent, 100); + assert.equal(status?.batteryStatusCode, 2); + assert.equal(status?.charging, false); + assert.equal(status?.dpiStage, 2); + assert.equal(status?.ledModeCode, 0); + assert.equal(status?.ledMode, "Off"); + assert.equal(status?.maxPollingCode, 1); + assert.equal(status?.sensorType, 0x20); }); diff --git a/src/ryunix/kyu-pro-mx1.ts b/src/ryunix/kyu-pro-mx1.ts index 5aa0aa9e..5d4915bf 100644 --- a/src/ryunix/kyu-pro-mx1.ts +++ b/src/ryunix/kyu-pro-mx1.ts @@ -1,3 +1,5 @@ +import { ryunixPollingHz } from "./protocol.ts"; + export const RYUNIX_VENDOR_ID = 0x04f3; export const RYUNIX_WIRED_PRODUCT_ID = 0x026e; export const RYUNIX_WIRELESS_PRODUCT_ID = 0x026f; @@ -8,6 +10,7 @@ export const RYUNIX_PRODUCT_IDS: ReadonlySet = new Set([ export const RYUNIX_USAGE_PAGE = 0x0a; export const RYUNIX_USAGE = 0xc7; + export const RYUNIX_TELEMETRY_REPORT_ID = 0x04; export const RYUNIX_CONFIG_REPORT_ID = 0x05; @@ -20,13 +23,6 @@ export type RyunixLedMode = | "Reactive" | "Cycling"; -const POLLING_RATES: Readonly> = { - 0x08: 125, - 0x04: 250, - 0x02: 500, - 0x01: 1000, -}; - const LED_MODES: Readonly> = { 0x00: "Off", 0x01: "Spectrum", @@ -44,11 +40,20 @@ export interface KyuProMx1Telemetry { pollingRateHz: number; batteryPercent: number; charging: boolean; + /** + * Offset 4 raw: 0 discharging, 1 charging. 2 was captured from a wired XS + * Flow 2 Speed at 100% and is read as full; other values are kept as sent. + */ + batteryStatusCode: number; ledMode: RyunixLedMode | null; ledModeCode: number; + /** Offset 7: the mouse's top polling-rate class (see `ryunixSupportedPollingRates`). */ + maxPollingCode: number | null; + /** Offset 8: the sensor type (see `RYUNIX_SENSORS`). */ + sensorType: number | null; } -/** Decode the six-byte telemetry body delivered by input report 0x04. */ +/** Decode the telemetry body delivered by input report 0x04 (six bytes minimum, nine with sensor type). */ export function decodeKyuProMx1Telemetry( data: Uint8Array, reportId: number, @@ -56,14 +61,14 @@ export function decodeKyuProMx1Telemetry( if (reportId !== RYUNIX_TELEMETRY_REPORT_ID || data.length < 6) return null; const activeByte = data[0]; - const pollingRateHz = POLLING_RATES[data[2]]; + const pollingRateHz = ryunixPollingHz(data[2]); const batteryPercent = data[3]; - const chargingByte = data[4]; + const batteryStatusCode = data[4]; if ( (activeByte !== 0 && activeByte !== 1) - || pollingRateHz === undefined + || pollingRateHz === null || batteryPercent > 100 - || (chargingByte !== 0 && chargingByte !== 1) + || batteryStatusCode > 2 ) return null; return { @@ -71,8 +76,11 @@ export function decodeKyuProMx1Telemetry( dpiStage: data[1], pollingRateHz, batteryPercent, - charging: chargingByte === 1, + charging: batteryStatusCode === 1, + batteryStatusCode, ledMode: LED_MODES[data[5]] ?? null, ledModeCode: data[5], + maxPollingCode: data.length > 7 ? data[7] : null, + sensorType: data.length > 8 && data[8] !== 0 ? data[8] : null, }; } diff --git a/src/ryunix/protocol.test.ts b/src/ryunix/protocol.test.ts new file mode 100644 index 00000000..97e3bbd0 --- /dev/null +++ b/src/ryunix/protocol.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RYUNIX_BUTTON_ACTIONS, + RYUNIX_BUTTON_FACTORY_CODES, + RYUNIX_BUTTON_SLOTS, + RYUNIX_COMMAND_FRAME_LENGTH, + ryunixAckOk, + ryunixButtonActionName, + ryunixClampDpi, + ryunixDefaultDpiStages, + ryunixFrame, + ryunixResetFrame, + ryunixSensor, + ryunixSetButtonsFrames, + ryunixSetDpiColorsFrame, + ryunixSetDpiFrame, + ryunixSetLiftOffFrame, + ryunixSetLightingFrame, + ryunixSetPollingRateFrame, + ryunixSetPowerFrame, + ryunixSetSensorOptionsFrame, + ryunixSupportedPollingRates, +} from "./protocol.ts"; + +/** The vendor app's own framing, re-implemented: pad to 30, then splice `6 + sum` in at index 3. */ +function vendorFrame(head: number[], pad: number): number[] { + const body = [...head]; + while (body.length < 30) body.push(pad); + let checksum = 6; + for (const byte of body) checksum += byte; + body.splice(3, 0, checksum & 0xff); + return body; +} + +test("frames match the vendor app's framing and checksum", () => { + assert.deepEqual([...ryunixResetFrame(1)], vendorFrame([15, 1, 1, 255], 0)); + assert.equal(ryunixResetFrame(1)[3], 0x16); + assert.deepEqual([...ryunixSetPollingRateFrame(5, 1000)], vendorFrame([4, 5, 1, 1], 0)); + assert.deepEqual([...ryunixSetLiftOffFrame(3, 2)], vendorFrame([5, 3, 1, 2], 255)); + assert.equal(ryunixSetLiftOffFrame(3, 2)[3], 0xf7); + assert.equal(ryunixResetFrame(1).length, RYUNIX_COMMAND_FRAME_LENGTH); +}); + +test("frames reject oversized payloads and non-byte values", () => { + assert.throws(() => ryunixFrame(1, 1, 1, new Array(28).fill(0)), RangeError); + assert.throws(() => ryunixFrame(1, 1, 1, [256]), RangeError); + assert.throws(() => ryunixFrame(1, 1, 1, [-1]), RangeError); +}); + +test("polling rate frames cover every code and refuse unknown rates", () => { + const codes: Array<[number, number]> = [[125, 8], [250, 4], [500, 2], [1000, 1], [2000, 17], [4000, 18], [8000, 20]]; + for (const [hz, code] of codes) assert.equal(ryunixSetPollingRateFrame(1, hz)[4], code); + assert.throws(() => ryunixSetPollingRateFrame(1, 750), RangeError); +}); + +test("polling rate options follow the reported maximum", () => { + assert.deepEqual(ryunixSupportedPollingRates(1), [125, 250, 500, 1000]); + assert.deepEqual(ryunixSupportedPollingRates(null), [125, 250, 500, 1000]); + assert.deepEqual(ryunixSupportedPollingRates(2), [125, 250, 500, 1000, 2000]); + assert.deepEqual(ryunixSupportedPollingRates(4), [125, 250, 500, 1000, 2000, 4000]); + assert.deepEqual(ryunixSupportedPollingRates(8), [125, 250, 500, 1000, 2000, 4000, 8000]); + assert.deepEqual(ryunixSupportedPollingRates(0x31), [125, 250]); +}); + +test("lift-off distance accepts only the vendor's two heights", () => { + assert.equal(ryunixSetLiftOffFrame(1, 1)[4], 1); + assert.throws(() => ryunixSetLiftOffFrame(1, 3), RangeError); +}); + +test("DPI table frames encode wire values per sensor and pad unused slots", () => { + const s3220 = ryunixSensor(0x20); + const frame = ryunixSetDpiFrame(2, { stages: [400, 800, 1600], activeStage: 1 }, s3220); + const expected = vendorFrame([2, 2, 2, 1, 0b111, 3, 0, 7, 0, 15, 0, ...new Array(10).fill(255)], 255); + assert.deepEqual([...frame], expected); + + // 3395 and 3950 count in 50 DPI steps. + const s3395 = ryunixSensor(0x95); + const fine = ryunixSetDpiFrame(2, { stages: [400, 26000], activeStage: 0 }, s3395); + assert.deepEqual([...fine.subarray(6, 10)], [7, 0, 0x07, 0x02]); + assert.equal(ryunixSetDpiFrame(2, { stages: [400], activeStage: 0 }, s3220, "y")[2], 0x12); +}); + +test("DPI table frames validate stage counts, active stage and DPI values", () => { + const sensor = ryunixSensor(0x20); + assert.throws(() => ryunixSetDpiFrame(1, { stages: [], activeStage: 0 }, sensor), RangeError); + assert.throws(() => ryunixSetDpiFrame(1, { stages: new Array(8).fill(800), activeStage: 0 }, sensor), RangeError); + assert.throws(() => ryunixSetDpiFrame(1, { stages: [800], activeStage: 1 }, sensor), RangeError); + assert.throws(() => ryunixSetDpiFrame(1, { stages: [150], activeStage: 0 }, sensor), RangeError); + assert.throws(() => ryunixSetDpiFrame(1, { stages: [850], activeStage: 0 }, sensor), RangeError); + assert.throws(() => ryunixSetDpiFrame(1, { stages: [8100], activeStage: 0 }, sensor), RangeError); +}); + +test("sensor defaults and DPI clamping follow the vendor tables", () => { + assert.equal(ryunixSensor(null).name, "3220"); + assert.equal(ryunixSensor(0xee).name, "3220"); + assert.equal(ryunixSensor(0x95).maxDpi, 26000); + assert.equal(ryunixClampDpi(120, ryunixSensor(0x20)), 200); + assert.equal(ryunixClampDpi(99999, ryunixSensor(0x20)), 8000); + assert.equal(ryunixClampDpi(1349, ryunixSensor(0x20)), 1300); + assert.deepEqual(ryunixDefaultDpiStages(ryunixSensor(0x20), 4), [400, 800, 1600, 2400]); + assert.deepEqual(ryunixDefaultDpiStages(ryunixSensor(0x20)), [400, 800, 1600, 2400, 3200, 6400, 8000]); +}); + +test("DPI colour frames carry eight RGB triples", () => { + const frame = ryunixSetDpiColorsFrame(4, ["#ff0000", "#0066ff"]); + assert.deepEqual([...frame.subarray(4, 10)], [255, 0, 0, 0, 102, 255]); + assert.equal(frame.length, RYUNIX_COMMAND_FRAME_LENGTH); + assert.throws(() => ryunixSetDpiColorsFrame(4, ["red"]), RangeError); + // Missing slots take their own defaults: stage 3 is blue, stage 7 white. + assert.deepEqual([...frame.subarray(10, 13)], [0, 0, 255]); + assert.deepEqual([...frame.subarray(22, 25)], [255, 255, 255]); +}); + +test("lighting frames only carry the controls an effect has", () => { + assert.deepEqual([...ryunixSetLightingFrame(7, { effect: 3, brightness: 3 })], vendorFrame([6, 7, 1, 255, 3, 48, 0], 255)); + assert.equal(ryunixSetLightingFrame(7, { effect: 3, brightness: 3 })[3], 0x2f); + // Breathe: speed only, brightness ignored. + assert.equal(ryunixSetLightingFrame(1, { effect: 2, speed: 2, brightness: 4 })[6], 2); + // Rainbow flow adds direction. + assert.equal(ryunixSetLightingFrame(1, { effect: 1, speed: 1, direction: 1 })[7], 1); + // Out-of-range levels clamp like the vendor app. + assert.equal(ryunixSetLightingFrame(1, { effect: 3, brightness: 9 })[6], 0x40); + assert.equal(ryunixSetLightingFrame(1, { effect: 2, speed: 0 })[6], 1); + assert.throws(() => ryunixSetLightingFrame(1, { effect: 99 }), RangeError); +}); + +test("power and sensor option frames match the vendor layout", () => { + assert.deepEqual([...ryunixSetPowerFrame(9, { sleepMinutes: 5, wakeOnMove: true, lightOnMove: false })], vendorFrame([7, 9, 1, 5, 1, 0], 255)); + assert.throws(() => ryunixSetPowerFrame(1, { sleepMinutes: 0, wakeOnMove: true, lightOnMove: false }), RangeError); + assert.throws(() => ryunixSetPowerFrame(1, { sleepMinutes: 16, wakeOnMove: true, lightOnMove: false }), RangeError); + assert.deepEqual( + [...ryunixSetSensorOptionsFrame(2, { angleSnapping: true, motionSync: false, rippleControl: true })], + vendorFrame([8, 2, 1, 1, 0, 1], 255), + ); +}); + +test("button frames send positions in firmware order, with unset slots as 0xFF", () => { + const left = RYUNIX_BUTTON_ACTIONS.get("Left click")!; + const dpi = RYUNIX_BUTTON_ACTIONS.get("DPI cycle")!; + const [first, second] = ryunixSetButtonsFrames([4, 5], [left, null, null, null, null, null, dpi]); + assert.deepEqual([...first.subarray(0, 3)], [1, 4, 2]); + assert.deepEqual([...first.subarray(4, 7)], [0x10, 0x01, 0x00]); + assert.deepEqual([...first.subarray(7, 10)], [255, 255, 255]); + assert.deepEqual([...first.subarray(22, 25)], [0x40, 0x01, 0x00]); + assert.deepEqual([...second.subarray(0, 3)], [1, 5, 0x12]); + assert.deepEqual([...second.subarray(4, 7)], [255, 255, 255]); + assert.equal(first.length, RYUNIX_COMMAND_FRAME_LENGTH); +}); + +test("button actions are unique codes that round-trip by name", () => { + const seen = new Set(); + for (const [name, code] of RYUNIX_BUTTON_ACTIONS) { + const key = code.join(","); + assert.equal(seen.has(key), false, `${name} duplicates another action`); + seen.add(key); + assert.equal(ryunixButtonActionName(code), name); + } + assert.equal(ryunixButtonActionName([1, 2, 3]), null); +}); + +test("acknowledgements need byte 1 set", () => { + assert.equal(ryunixAckOk(Uint8Array.of(0, 1)), true); + assert.equal(ryunixAckOk(Uint8Array.of(0, 0)), false); + assert.equal(ryunixAckOk(Uint8Array.of(1)), false); +}); + +test("the factory layout covers every position and agrees with the slot defaults", () => { + assert.equal(RYUNIX_BUTTON_FACTORY_CODES.length, 10); + RYUNIX_BUTTON_SLOTS.forEach((slot, index) => { + assert.deepEqual(RYUNIX_BUTTON_FACTORY_CODES[index], RYUNIX_BUTTON_ACTIONS.get(slot.factory), slot.name); + }); + assert.deepEqual(RYUNIX_BUTTON_FACTORY_CODES[9], RYUNIX_BUTTON_ACTIONS.get("Mode switch")); +}); diff --git a/src/ryunix/protocol.ts b/src/ryunix/protocol.ts new file mode 100644 index 00000000..fb3f93d6 --- /dev/null +++ b/src/ryunix/protocol.ts @@ -0,0 +1,461 @@ +/** + * Configuration protocol of the 0x04F3:0x026E/0x026F OEM mouse firmware, as + * used by Amkette's XS Flow series and the Ryunix Kyu Pro MX1. Reverse-engineered + * from Amkette's public WebHID configurator + * (https://support.amkette.com/webapp/xs_flow_2_speed/); nothing here has been + * seen in a vendor binary. + * + * Transport: the mouse's control collection (usage page 0x0A) carries + * - input report 0x04: telemetry (see `kyu-pro-mx1.ts`); + * - feature report 0x05: status read, which makes the mouse emit report 0x04; + * - feature report 0x06: 31-byte command write; reading it back returns the + * acknowledgement, whose byte 1 is 1 when the command was accepted. + * + * Command frame (31 bytes): `[command, sequence, sub, checksum, ...payload]`. + * The checksum is `(6 + sum of the other 30 bytes) & 0xFF` and sits at offset + * 3. The vendor pads payloads with 0xFF, except polling rate and reset (0x00). + * The mouse never reports DPI values, lift-off distance, sleep time or button + * maps back, so those are write-only; only the active DPI stage, polling rate, + * battery and lighting mode arrive in telemetry. + * + * Only the Amkette XS driver (`src/drivers/amkette/xs-hid.ts`) sends these + * commands, and they have only been checked on an Amkette mouse. The Ryunix Kyu + * Pro MX1 driver reads telemetry only; do not wire these writes into it before + * they have been checked on that mouse. + */ + +export const RYUNIX_COMMAND_REPORT_ID = 0x06; +export const RYUNIX_COMMAND_FRAME_LENGTH = 31; + +export const RYUNIX_COMMAND = { + buttons: 0x01, + dpi: 0x02, + dpiColors: 0x03, + pollingRate: 0x04, + liftOffDistance: 0x05, + lighting: 0x06, + power: 0x07, + sensorOptions: 0x08, + macroWrite: 0x10, + macroErase: 0x11, + reset: 0x0f, +} as const; + +/** Sub-command of the DPI table write: X axis, then the Y axis copy. */ +const DPI_SUB_X = 0x02; +const DPI_SUB_Y = 0x12; + +export const RYUNIX_DPI_SLOTS = 8; +export const RYUNIX_DPI_MAX_STAGES = 7; +export const RYUNIX_DPI_MIN = 200; +export const RYUNIX_DPI_STEP = 100; + +/** Sequence counter the vendor app increments per command, wrapping at 256. */ +export function ryunixNextSequence(current: number): number { + return (current + 1) & 0xff; +} + +/** + * Build one command frame. `payload` is placed after the checksum and padded + * with `pad` to the fixed frame length. + */ +export function ryunixFrame( + command: number, + sequence: number, + sub: number, + payload: readonly number[], + pad = 0xff, +): Uint8Array { + const bodyLength = RYUNIX_COMMAND_FRAME_LENGTH - 1; + if (payload.length > bodyLength - 3) { + throw new RangeError(`Ryunix payload is ${payload.length} bytes; at most ${bodyLength - 3} fit.`); + } + const body = new Uint8Array(bodyLength).fill(pad); + body[0] = command; + body[1] = sequence & 0xff; + body[2] = sub; + payload.forEach((byte, index) => { + if (!Number.isInteger(byte) || byte < 0 || byte > 0xff) { + throw new RangeError(`Ryunix payload byte ${index} (${byte}) is not a byte.`); + } + body[3 + index] = byte; + }); + let checksum = 6; + for (const byte of body) checksum += byte; + + const frame = new Uint8Array(RYUNIX_COMMAND_FRAME_LENGTH); + frame.set(body.subarray(0, 3), 0); + frame[3] = checksum & 0xff; + frame.set(body.subarray(3), 4); + return frame; +} + +/** True when the acknowledgement read from report 0x06 reports success. */ +export function ryunixAckOk(ack: Uint8Array): boolean { + return ack.length > 1 && ack[1] === 1; +} + +/** Polling-rate codes the mouse accepts and reports. */ +export const RYUNIX_POLLING_CODES: ReadonlyArray = [ + [0x08, 125], + [0x04, 250], + [0x02, 500], + [0x01, 1000], + [0x11, 2000], + [0x12, 4000], + [0x14, 8000], +]; + +export function ryunixPollingHz(code: number): number | null { + return RYUNIX_POLLING_CODES.find(([value]) => value === code)?.[1] ?? null; +} + +/** + * Rates a mouse offers, from the "maximum report rate" byte of its telemetry + * (offset 7). 8/4/2/1 mean the mouse reaches 8000/4000/2000/1000 Hz; 0x31 + * limits it to 250 Hz. + */ +export function ryunixSupportedPollingRates(maxRateCode: number | null): number[] { + const base = [125, 250, 500, 1000]; + switch (maxRateCode) { + case 8: return [...base, 2000, 4000, 8000]; + case 4: return [...base, 2000, 4000]; + case 2: return [...base, 2000]; + case 0x31: return [125, 250]; + default: return base; + } +} + +export function ryunixSetPollingRateFrame(sequence: number, hz: number): Uint8Array { + const entry = RYUNIX_POLLING_CODES.find(([, rate]) => rate === hz); + if (!entry) throw new RangeError(`Unsupported polling rate ${hz} Hz.`); + return ryunixFrame(RYUNIX_COMMAND.pollingRate, sequence, 1, [entry[0]], 0x00); +} + +/** Lift-off distance in millimetres; the vendor app offers 1 and 2. */ +export const RYUNIX_LIFT_OFF_MM = [1, 2] as const; + +export function ryunixSetLiftOffFrame(sequence: number, millimetres: number): Uint8Array { + if (!(RYUNIX_LIFT_OFF_MM as readonly number[]).includes(millimetres)) { + throw new RangeError(`Unsupported lift-off distance ${millimetres} mm.`); + } + return ryunixFrame(RYUNIX_COMMAND.liftOffDistance, sequence, 1, [millimetres]); +} + +export interface RyunixSensor { + name: string; + maxDpi: number; + /** DPI resolution of the wire value: 50 or 100 counts per step. */ + step: number; + /** The vendor app's default stage table for this sensor (eight slots). */ + defaultStages: readonly number[]; +} + +/** Sensor types the telemetry reports (offset 8), with the vendor's DPI limits. */ +export const RYUNIX_SENSORS: ReadonlyMap = new Map([ + [0x11, { name: "3311", maxDpi: 12000, step: 100, defaultStages: [400, 800, 1600, 2400, 3200, 6400, 12000, 12000] }], + [0x95, { name: "3395", maxDpi: 26000, step: 50, defaultStages: [400, 800, 1600, 3200, 6400, 26000, 26000, 26000] }], + [0x50, { name: "3950", maxDpi: 30000, step: 50, defaultStages: [400, 800, 1600, 3200, 6400, 30000, 30000, 30000] }], + [0x20, { name: "3220", maxDpi: 8000, step: 100, defaultStages: [400, 800, 1600, 2400, 3200, 6400, 8000, 8000] }], + [0x12, { name: "3212", maxDpi: 4800, step: 100, defaultStages: [400, 800, 1200, 1600, 2400, 3200, 4800, 4800] }], + [0x09, { name: "3809", maxDpi: 6000, step: 100, defaultStages: [400, 800, 1600, 2400, 3200, 4800, 6000, 6000] }], + [0x03, { name: "S203", maxDpi: 6400, step: 100, defaultStages: [400, 800, 1200, 1600, 2400, 3200, 6400, 6400] }], + [0x06, { name: "S205", maxDpi: 12800, step: 100, defaultStages: [400, 800, 1600, 2400, 3200, 6400, 12800, 12800] }], + [0x70, { name: "OM70", maxDpi: 30000, step: 100, defaultStages: [400, 800, 1600, 3200, 6400, 30000, 30000, 30000] }], +]); + +/** The vendor app assumes the 3220 until the mouse reports its sensor. */ +export const RYUNIX_DEFAULT_SENSOR_TYPE = 0x20; + +export function ryunixSensor(sensorType: number | null): RyunixSensor { + return RYUNIX_SENSORS.get(sensorType ?? RYUNIX_DEFAULT_SENSOR_TYPE) + ?? RYUNIX_SENSORS.get(RYUNIX_DEFAULT_SENSOR_TYPE)!; +} + +export interface RyunixDpiTable { + /** DPI of each enabled stage, lowest slot first; 1 to 7 entries. */ + stages: readonly number[]; + /** Zero-based active stage. */ + activeStage: number; +} + +/** + * The vendor app's default table for a sensor, first `count` stages. Its UI + * shows seven stages with 1600 DPI active on the XS Flow 2 Speed. + */ +export function ryunixDefaultDpiStages(sensor: RyunixSensor, count = RYUNIX_DPI_MAX_STAGES): number[] { + return sensor.defaultStages.slice(0, count); +} + +export function ryunixClampDpi(dpi: number, sensor: RyunixSensor): number { + const stepped = Math.round(dpi / RYUNIX_DPI_STEP) * RYUNIX_DPI_STEP; + return Math.min(sensor.maxDpi, Math.max(RYUNIX_DPI_MIN, stepped)); +} + +function dpiWire(dpi: number, sensor: RyunixSensor): number { + return Math.floor(dpi / sensor.step) - 1; +} + +/** DPI table frame for one axis; the vendor app writes X, then Y with the same values. */ +export function ryunixSetDpiFrame( + sequence: number, + table: RyunixDpiTable, + sensor: RyunixSensor, + axis: "x" | "y" = "x", +): Uint8Array { + const count = table.stages.length; + if (count < 1 || count > RYUNIX_DPI_MAX_STAGES) { + throw new RangeError(`A DPI table needs 1 to ${RYUNIX_DPI_MAX_STAGES} stages, not ${count}.`); + } + if (!Number.isInteger(table.activeStage) || table.activeStage < 0 || table.activeStage >= count) { + throw new RangeError(`Active DPI stage ${table.activeStage} is outside 0-${count - 1}.`); + } + const payload: number[] = [table.activeStage, (1 << count) - 1]; + for (let slot = 0; slot < RYUNIX_DPI_SLOTS; slot++) { + if (slot >= count) { + // The vendor app encodes an unused slot as DPI 0, which wraps to 0xFFFF. + payload.push(0xff, 0xff); + continue; + } + const dpi = table.stages[slot]!; + if (dpi < RYUNIX_DPI_MIN || dpi > sensor.maxDpi || dpi % RYUNIX_DPI_STEP !== 0) { + throw new RangeError(`DPI ${dpi} is not a multiple of ${RYUNIX_DPI_STEP} within ${RYUNIX_DPI_MIN}-${sensor.maxDpi}.`); + } + const wire = dpiWire(dpi, sensor); + payload.push(wire & 0xff, (wire >> 8) & 0xff); + } + return ryunixFrame(RYUNIX_COMMAND.dpi, sequence, axis === "x" ? DPI_SUB_X : DPI_SUB_Y, payload); +} + +/** Stage indicator colours the vendor app shows by default (the eighth is unused). */ +export const RYUNIX_DEFAULT_DPI_COLORS: readonly string[] = [ + "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff", "#ff00ff", "#ffffff", "#ffffff", +]; + +export function ryunixParseColor(color: string): [number, number, number] { + const match = /^#([0-9a-f]{6})$/i.exec(color); + if (!match) throw new RangeError(`"${color}" is not a #rrggbb colour.`); + const value = parseInt(match[1]!, 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} + +/** Per-stage DPI indicator colours (eight RGB triples). */ +export function ryunixSetDpiColorsFrame(sequence: number, colors: readonly string[]): Uint8Array { + const payload: number[] = []; + for (let slot = 0; slot < RYUNIX_DPI_SLOTS; slot++) { + payload.push(...ryunixParseColor(colors[slot] ?? RYUNIX_DEFAULT_DPI_COLORS[slot]!)); + } + return ryunixFrame(RYUNIX_COMMAND.dpiColors, sequence, 1, payload); +} + +export interface RyunixLightingEffect { + code: number; + name: string; + brightness: boolean; + speed: boolean; + direction: boolean; +} + +/** Effects the vendor app can address; which a model offers is `RYUNIX_LIGHTING_SCHEMES`. */ +export const RYUNIX_LIGHTING_EFFECTS: ReadonlyMap = new Map([ + [0, { code: 0, name: "Off", brightness: false, speed: false, direction: false }], + [1, { code: 1, name: "Rainbow flow", brightness: false, speed: true, direction: true }], + [2, { code: 2, name: "Breathe", brightness: false, speed: true, direction: false }], + [3, { code: 3, name: "Steady", brightness: true, speed: false, direction: false }], + [4, { code: 4, name: "Neon", brightness: false, speed: true, direction: false }], + [5, { code: 5, name: "Marquee", brightness: false, speed: true, direction: false }], + [6, { code: 6, name: "Rainbow steady", brightness: true, speed: false, direction: false }], + [7, { code: 7, name: "Wave", brightness: false, speed: true, direction: false }], +]); + +/** Effect codes per model scheme in the vendor app; the XS Flow 2 Speed uses scheme 1. */ +export const RYUNIX_LIGHTING_SCHEMES: Readonly> = { + 1: [3, 2, 0], + 2: [3, 2, 4, 0], + 3: [1, 2, 3, 4, 5, 6, 7, 0], + 4: [], +}; + +export const RYUNIX_BRIGHTNESS_LEVELS = [1, 2, 3, 4] as const; +export const RYUNIX_SPEED_LEVELS = [1, 2, 3] as const; + +export interface RyunixLightingSetting { + effect: number; + /** 1-4, used by effects with a brightness control. */ + brightness?: number; + /** 1-3, used by effects with a speed control. */ + speed?: number; + direction?: number; +} + +export function ryunixSetLightingFrame(sequence: number, setting: RyunixLightingSetting): Uint8Array { + const effect = RYUNIX_LIGHTING_EFFECTS.get(setting.effect); + if (!effect) throw new RangeError(`Unknown lighting effect ${setting.effect}.`); + const brightness = effect.brightness + ? Math.min(Math.max(setting.brightness ?? 4, 1), 4) << 4 + : 0; + const speed = effect.speed ? Math.min(Math.max(setting.speed ?? 3, 1), 3) : 0; + return ryunixFrame(RYUNIX_COMMAND.lighting, sequence, 1, [ + 0xff, + effect.code, + brightness | speed, + setting.direction ?? 0, + ]); +} + +export const RYUNIX_SLEEP_MIN_MINUTES = 1; +export const RYUNIX_SLEEP_MAX_MINUTES = 15; + +export interface RyunixPowerSetting { + sleepMinutes: number; + wakeOnMove: boolean; + lightOnMove: boolean; +} + +export function ryunixSetPowerFrame(sequence: number, setting: RyunixPowerSetting): Uint8Array { + if ( + !Number.isInteger(setting.sleepMinutes) + || setting.sleepMinutes < RYUNIX_SLEEP_MIN_MINUTES + || setting.sleepMinutes > RYUNIX_SLEEP_MAX_MINUTES + ) { + throw new RangeError(`Sleep time must be ${RYUNIX_SLEEP_MIN_MINUTES}-${RYUNIX_SLEEP_MAX_MINUTES} minutes.`); + } + return ryunixFrame(RYUNIX_COMMAND.power, sequence, 1, [ + setting.sleepMinutes, + setting.wakeOnMove ? 1 : 0, + setting.lightOnMove ? 1 : 0, + ]); +} + +export interface RyunixSensorOptions { + angleSnapping: boolean; + motionSync: boolean; + rippleControl: boolean; +} + +export function ryunixSetSensorOptionsFrame(sequence: number, options: RyunixSensorOptions): Uint8Array { + return ryunixFrame(RYUNIX_COMMAND.sensorOptions, sequence, 1, [ + options.angleSnapping ? 1 : 0, + options.motionSync ? 1 : 0, + options.rippleControl ? 1 : 0, + ]); +} + +/** Restore factory settings. Not used by the driver; documented for completeness. */ +export function ryunixResetFrame(sequence: number): Uint8Array { + return ryunixFrame(RYUNIX_COMMAND.reset, sequence, 1, [0xff], 0x00); +} + +/** A button action as the mouse stores it: three bytes `[kind, a, b]`. */ +export type RyunixButtonCode = readonly [number, number, number]; + +/** Actions the vendor app offers, by display name. Keyboard shortcuts are `[0x70, modifier, HID key]`. */ +export const RYUNIX_BUTTON_ACTIONS: ReadonlyMap = new Map([ + ["Left click", [0x10, 0x01, 0x00]], + ["Right click", [0x10, 0x02, 0x00]], + ["Middle click", [0x10, 0x04, 0x00]], + ["Back", [0x10, 0x08, 0x00]], + ["Forward", [0x10, 0x10, 0x00]], + ["Scroll up", [0x10, 0x20, 0x00]], + ["Scroll down", [0x10, 0x40, 0x00]], + ["Fire key", [0x30, 0x32, 0x00]], + ["Triple fire", [0x30, 0x32, 0x03]], + ["DPI cycle", [0x40, 0x01, 0x00]], + ["DPI up", [0x40, 0x02, 0x00]], + ["DPI down", [0x40, 0x03, 0x00]], + ["Disabled", [0x60, 0x00, 0x00]], + ["Mode switch", [0x60, 0x01, 0x00]], + ["Lighting toggle", [0x60, 0x04, 0x00]], + ["Tilt left", [0x60, 0x06, 0x00]], + ["Tilt right", [0x60, 0x07, 0x00]], + ["Show desktop", [0x60, 0x08, 0x00]], + ["Select all", [0x70, 0x01, 0x04]], + ["Copy", [0x70, 0x01, 0x06]], + ["New", [0x70, 0x01, 0x11]], + ["Print", [0x70, 0x01, 0x13]], + ["Save", [0x70, 0x01, 0x16]], + ["Paste", [0x70, 0x01, 0x19]], + ["Cut", [0x70, 0x01, 0x1b]], + ["Undo", [0x70, 0x01, 0x1d]], + ["Open", [0x70, 0x08, 0x08]], + ["Media player", [0x80, 0x83, 0x01]], + ["Email", [0x80, 0x8a, 0x01]], + ["Calculator", [0x80, 0x92, 0x01]], + ["Next track", [0x80, 0xb5, 0x00]], + ["Previous track", [0x80, 0xb6, 0x00]], + ["Stop", [0x80, 0xb7, 0x00]], + ["Play or pause", [0x80, 0xcd, 0x00]], + ["Mute", [0x80, 0xe2, 0x00]], + ["Volume up", [0x80, 0xe9, 0x00]], + ["Volume down", [0x80, 0xea, 0x00]], +] as Array<[string, RyunixButtonCode]>); + +export function ryunixButtonActionName(code: RyunixButtonCode): string | null { + for (const [name, value] of RYUNIX_BUTTON_ACTIONS) { + if (value[0] === code[0] && value[1] === code[1] && value[2] === code[2]) return name; + } + return null; +} + +/** + * Button positions in the order the firmware stores them (the vendor app sends + * its buttons as 0,1,2,6,5,3,4,7,8, which is this order). Names follow the + * physical layout in Amkette's XS Flow 2 Speed configurator: its buttons 4 and + * 5 are the two small buttons under the wheel (default "show desktop" and "DPI + * cycle"), 6 and 7 the thumb pair, 8 and 9 the wheel tilt. Its button 10, the + * bottom mode switch, is left out so the 2.4 GHz/Bluetooth/USB switch cannot be + * remapped away. + */ +export const RYUNIX_BUTTON_SLOTS = [ + { id: "Left", name: "Left button", factory: "Left click" }, + { id: "Right", name: "Right button", factory: "Right click" }, + { id: "Middle", name: "Wheel button", factory: "Middle click" }, + { id: "Back", name: "Back button", factory: "Back" }, + { id: "Forward", name: "Forward button", factory: "Forward" }, + { id: "Upper", name: "Upper centre button", factory: "Show desktop" }, + { id: "Dpi", name: "DPI button", factory: "DPI cycle" }, + { id: "TiltLeft", name: "Wheel tilt left", factory: "Tilt left" }, + { id: "TiltRight", name: "Wheel tilt right", factory: "Tilt right" }, +] as const; + +export const RYUNIX_BUTTON_POSITIONS = 10; + +/** + * What each of the ten positions holds in the vendor app's default layout, in + * firmware order. Position 9 is the bottom mode switch, which is not offered for + * remapping but is part of every button write. The vendor app writes all ten + * from this layout, so a write that has to fill positions it was not asked to + * change uses these rather than 0xFF, whose meaning on this firmware is unknown. + */ +export const RYUNIX_BUTTON_FACTORY_CODES: readonly RyunixButtonCode[] = [ + [0x10, 0x01, 0x00], + [0x10, 0x02, 0x00], + [0x10, 0x04, 0x00], + [0x10, 0x08, 0x00], + [0x10, 0x10, 0x00], + [0x60, 0x08, 0x00], + [0x40, 0x01, 0x00], + [0x60, 0x06, 0x00], + [0x60, 0x07, 0x00], + [0x60, 0x01, 0x00], +]; + +/** + * Button table frames for the ten-position layout the XS Flow configurator + * uses: positions 0-8 in a first frame (sub 0x02) and position 9 in a second + * (sub 0x12). `positions[i]` is `null` for 0xFF 0xFF 0xFF, which the vendor app + * writes for a button it has no assignment for. + */ +export function ryunixSetButtonsFrames( + sequences: readonly [first: number, second: number], + positions: ReadonlyArray, +): [Uint8Array, Uint8Array] { + const unset: RyunixButtonCode = [0xff, 0xff, 0xff]; + const at = (index: number): RyunixButtonCode => positions[index] ?? unset; + const first: number[] = []; + for (let index = 0; index < 9; index++) first.push(...at(index)); + return [ + ryunixFrame(RYUNIX_COMMAND.buttons, sequences[0], 0x02, first), + ryunixFrame(RYUNIX_COMMAND.buttons, sequences[1], 0x12, [...at(9)]), + ]; +}