From 1214dbbcf08f1b09c555700c1ea48dd5b171947c Mon Sep 17 00:00:00 2001 From: Kunal Agrawal <66416646+kunalagra@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:51:11 +0530 Subject: [PATCH 1/5] feat: add Amkette XS series support Amkette's XS mice share VID 0x04F3 and PIDs 0x026E/0x026F with the Ryunix Kyu Pro MX1 and use the same OEM firmware protocol, reverse-engineered from Amkette's public WebHID configurators. - src/ryunix/protocol.ts: shared 31-byte command framing and checksum, encoders for DPI table and stage colours, polling rate, lift-off, lighting, power, sensor options and buttons, plus sensor and button tables. - src/amkette: model identity (USB product strings, Bluetooth slot names), Bluetooth PID 0x026D and the usage-0x00 configuration collection, exported as @openmouse/protocol/amkette. - src/drivers/amkette/xs-hid.ts: read/write driver with acknowledgement polling, confirmation against fresh telemetry, and write-only state for settings the mouse cannot report. Registered and filtered on usage 0x00. - The Ryunix Kyu Pro MX1 driver (usage 0xC7) is unchanged and stays read-only. Its telemetry decoder now also accepts battery status 2 and the 2/4/8 kHz polling codes, and reports the top polling class and sensor type. - docs/amkette-xs-flow-testing.md records the evidence, what was verified on hardware over USB, the 2.4 GHz receiver and Bluetooth, and what is not. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/amkette-xs-flow-testing.md | 266 +++++++++++ package.json | 4 + src/amkette/index.ts | 1 + src/amkette/xs.test.ts | 52 ++ src/amkette/xs.ts | 103 ++++ src/drivers/amkette/xs-hid.test.ts | 517 ++++++++++++++++++++ src/drivers/amkette/xs-hid.ts | 733 +++++++++++++++++++++++++++++ src/drivers/mouse-types.ts | 2 +- src/drivers/registry.ts | 7 +- src/drivers/vendors.ts | 13 + src/index.ts | 1 + src/ryunix/index.ts | 1 + src/ryunix/kyu-pro-mx1.test.ts | 34 ++ src/ryunix/kyu-pro-mx1.ts | 34 +- src/ryunix/protocol.test.ts | 172 +++++++ src/ryunix/protocol.ts | 461 ++++++++++++++++++ 17 files changed, 2387 insertions(+), 15 deletions(-) create mode 100644 docs/amkette-xs-flow-testing.md create mode 100644 src/amkette/index.ts create mode 100644 src/amkette/xs.test.ts create mode 100644 src/amkette/xs.ts create mode 100644 src/drivers/amkette/xs-hid.test.ts create mode 100644 src/drivers/amkette/xs-hid.ts create mode 100644 src/ryunix/protocol.test.ts create mode 100644 src/ryunix/protocol.ts 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..4b85af09 --- /dev/null +++ b/docs/amkette-xs-flow-testing.md @@ -0,0 +1,266 @@ +# 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 is unchanged: read-only telemetry. 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). + +## Not yet verified + +These are encoded from the vendor app and covered by codec tests, but have not +been checked against the mouse: the DPI table and stage colours, active stage +switching, 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, 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 63308fcd..81a39958 100644 --- a/package.json +++ b/package.json @@ -206,6 +206,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..9cfbd37d --- /dev/null +++ b/src/drivers/amkette/xs-hid.ts @@ -0,0 +1,733 @@ +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, + valuesVerified: telemetry !== null, + 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 2ca10ea0..f943a28e 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -204,7 +204,7 @@ export interface MagneticCalibrationProgress { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Vancer" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ" | "Scyrox" | "Hitscan"; + 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" | "Scyrox" | "Hitscan"; 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 8b84373a..2f56fac7 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -90,6 +90,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"; @@ -98,7 +99,7 @@ import { AjazzAk820HidClient } from "./ajazz/ak820-hid.ts"; import { HitscanHidClient } from "./hitscan/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 | GloriousSinowealthHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient | ScyroxHidClient | HitscanHidClient; +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 | GloriousSinowealthHidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | AmketteXsHidClient | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient | ScyroxHidClient | HitscanHidClient; export interface DeviceDriver { brand: string; supports(device: HIDDevice): boolean; @@ -229,6 +230,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. @@ -257,6 +261,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/vendors.ts b/src/drivers/vendors.ts index 305f1880..af8e42b3 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -111,6 +111,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, @@ -154,6 +155,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, @@ -862,6 +866,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. @@ -1008,6 +1020,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 f11ccf89..6a583dcb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,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..c47f34c8 --- /dev/null +++ b/src/ryunix/protocol.test.ts @@ -0,0 +1,172 @@ +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); +}); + +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..468ee0ad --- /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[0]!)); + } + 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)]), + ]; +} From db97b6243d95f69d9cab254d4eb92a819c63ca6f Mon Sep 17 00:00:00 2001 From: Kunal Agrawal <66416646+kunalagra@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:23:41 +0530 Subject: [PATCH 2/5] fix: tighten Amkette XS status and DPI colour defaults - Amkette XS driver: stop marking values as verified, since DPI values are never read from the mouse; only polling and the active stage are. - Ryunix Kyu Pro MX1 driver: show battery status 2 as full at 100% (unknown otherwise) now that the shared decoder accepts it, instead of discharging. - ryunixSetDpiColorsFrame: fill a missing stage colour with that stage's own default rather than the first stage's red. - Integration note: DPI stage selection over USB is verified on hardware, and the Ryunix driver's one change is described. Co-Authored-By: Claude Opus 5.5 --- docs/amkette-xs-flow-testing.md | 16 ++++++++++------ src/drivers/amkette/xs-hid.ts | 3 ++- src/drivers/ryunix/kyu-pro-mx1-hid.test.ts | 11 +++++++++++ src/drivers/ryunix/kyu-pro-mx1-hid.ts | 5 ++++- src/ryunix/protocol.test.ts | 3 +++ src/ryunix/protocol.ts | 2 +- 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/amkette-xs-flow-testing.md b/docs/amkette-xs-flow-testing.md index 4b85af09..7a84886a 100644 --- a/docs/amkette-xs-flow-testing.md +++ b/docs/amkette-xs-flow-testing.md @@ -12,7 +12,9 @@ brands have separate drivers: 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 is unchanged: read-only telemetry. None + 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. @@ -139,16 +141,18 @@ independently. 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). + 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: the DPI table and stage colours, active stage -switching, auto sleep, angle snapping, motion sync, ripple control and button -remapping. Whether lift-off, brightness and effect speed change anything +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, polling rates above 1000 Hz, and the XS Flow Plus and XS Natural Plus +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. diff --git a/src/drivers/amkette/xs-hid.ts b/src/drivers/amkette/xs-hid.ts index 9cfbd37d..17d3de9a 100644 --- a/src/drivers/amkette/xs-hid.ts +++ b/src/drivers/amkette/xs-hid.ts @@ -521,7 +521,8 @@ export class AmketteXsHidClient { ui: { family: "amkette-xs", settingsReady: true, - valuesVerified: telemetry !== null, + // Polling and the active stage are read, but DPI values never are. + valuesVerified: false, hideUnsupportedPollingRates: true, hideSignalCard: true, forceShowBattery: true, 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/ryunix/protocol.test.ts b/src/ryunix/protocol.test.ts index c47f34c8..97e3bbd0 100644 --- a/src/ryunix/protocol.test.ts +++ b/src/ryunix/protocol.test.ts @@ -108,6 +108,9 @@ test("DPI colour frames carry eight RGB triples", () => { 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", () => { diff --git a/src/ryunix/protocol.ts b/src/ryunix/protocol.ts index 468ee0ad..fb3f93d6 100644 --- a/src/ryunix/protocol.ts +++ b/src/ryunix/protocol.ts @@ -243,7 +243,7 @@ export function ryunixParseColor(color: string): [number, number, number] { 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[0]!)); + payload.push(...ryunixParseColor(colors[slot] ?? RYUNIX_DEFAULT_DPI_COLORS[slot]!)); } return ryunixFrame(RYUNIX_COMMAND.dpiColors, sequence, 1, payload); } From 122b476122272c3fe30d0185ffac6e18da5707d9 Mon Sep 17 00:00:00 2001 From: Kunal Agrawal <66416646+kunalagra@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:06:26 +0530 Subject: [PATCH 3/5] fix(amkette): serialise read-modify-write setters and drop settled waiters Review follow-ups on the XS driver: - Setters that build a frame from write-only state (DPI stages, active stage, stage count, stage colours, sensor options, lighting, buttons) now read and update that state inside the queued transaction. Before, two concurrent calls such as setAngleSnapping(true) and setRippleControl(true) both built their frame from the state before either, so the second write turned the first one's option back off while both reported success. - A telemetry waiter removes itself when it settles, including on timeout, so status reads against a mouse that sends nothing no longer pile up closures until the next report. Co-Authored-By: Claude Opus 5.5 --- src/drivers/amkette/xs-hid.test.ts | 23 ++++ src/drivers/amkette/xs-hid.ts | 174 +++++++++++++++++------------ 2 files changed, 124 insertions(+), 73 deletions(-) diff --git a/src/drivers/amkette/xs-hid.test.ts b/src/drivers/amkette/xs-hid.test.ts index d45b24a6..d16fbae8 100644 --- a/src/drivers/amkette/xs-hid.test.ts +++ b/src/drivers/amkette/xs-hid.test.ts @@ -292,6 +292,16 @@ test("a timed-out confirmation does not leave a waiter behind", async () => { assert.equal((client as unknown as { telemetryWaiters: unknown[] }).telemetryWaiters.length, 0); }); +test("a status read that times out does not leave a waiter behind", async () => { + // The mouse answers nothing, so no report ever arrives to drain the waiters. + const { client } = await openClient({ + async receiveFeatureReport() { return new DataView(new ArrayBuffer(31)); }, + } as Partial); + await client.readStatus(); + await client.readStatus(); + 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]); @@ -515,3 +525,16 @@ test("commands from separate callers are serialised and sequenced", async () => assert.deepEqual(transport.sent.map((entry) => entry.data[1]), [1, 2, 3]); assert.deepEqual(transport.sent.map((entry) => entry.data[0]), [0x05, 0x07, 0x08]); }); + +test("concurrent writes build on each other instead of on the state before either", async () => { + const { client } = await openClient(); + await Promise.all([client.setAngleSnapping(true), client.setRippleControl(true)]); + await Promise.all([client.setDpiStageValue(0, 400), client.setDpiStageValue(1, 900)]); + await Promise.all([client.setButtonMapping("Back button", "Copy"), client.setButtonMapping("Forward button", "Paste")]); + const status = await client.readStatus(); + assert.equal(status.angleSnapping, true); + assert.equal(status.rippleControl, true); + assert.deepEqual(status.dpiStages?.slice(0, 2), [400, 900]); + assert.equal(status.buttonMappings?.["Back button"], "Copy"); + assert.equal(status.buttonMappings?.["Forward button"], "Paste"); +}); diff --git a/src/drivers/amkette/xs-hid.ts b/src/drivers/amkette/xs-hid.ts index 17d3de9a..4afc2c93 100644 --- a/src/drivers/amkette/xs-hid.ts +++ b/src/drivers/amkette/xs-hid.ts @@ -326,6 +326,7 @@ export class AmketteXsHidClient { const finish = (value: KyuProMx1Telemetry | null): void => { if (settled) return; settled = true; + this.telemetryWaiters = this.telemetryWaiters.filter((waiter) => waiter !== check); clearTimeout(timer); resolve(value); }; @@ -417,19 +418,29 @@ export class AmketteXsHidClient { 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)); - } - }); + return this.enqueue(() => this.transmit(build, confirm)); + } + + /** + * `send` without the queue, for setters that read and update write-only state + * inside their own queued transaction, so concurrent setters build on each + * other's writes rather than on the state before either. + */ + private async transmit( + build: (sequence: () => number) => Uint8Array | Uint8Array[], + confirm?: Confirmation, + ): Promise { + 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)); + } } /** @@ -449,6 +460,7 @@ export class AmketteXsHidClient { return state.dpiStages ? [...state.dpiStages] : ryunixDefaultDpiStages(ryunixSensor(this.telemetry?.sensorType ?? null)); } + /** Runs inside a queued transaction. */ private async writeDpiTable( stages: number[], activeStage: number, @@ -457,7 +469,7 @@ export class AmketteXsHidClient { const sensor = this.requireSensor(); const state = stateFor(this.device.productId); const table = { stages, activeStage }; - await this.send((sequence) => [ + await this.transmit((sequence) => [ ryunixSetDpiFrame(sequence(), table, sensor, "x"), ryunixSetDpiFrame(sequence(), table, sensor, "y"), ryunixSetDpiColorsFrame(sequence(), state.dpiColors), @@ -572,15 +584,18 @@ export class AmketteXsHidClient { 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 setDpi(dpi: number): Promise { + return this.enqueue(() => { + 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); + public setDpiStageValue(stage: number, dpi: number): Promise { + return this.enqueue(() => this.writeStageValue(this.tableFor(stateFor(this.device.productId)), stage, dpi)); } + /** Runs inside a queued transaction. */ 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}.`); @@ -598,42 +613,48 @@ export class AmketteXsHidClient { * 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.`, + public setActiveDpiStage(stage: number): Promise { + return this.enqueue(async () => { + 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; }); - 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; + return this.enqueue(async () => { + 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]!; + return this.enqueue(async () => { + const state = stateFor(this.device.productId); + const colors = [...state.dpiColors]; + colors[index] = color.toLowerCase(); + await this.transmit((sequence) => ryunixSetDpiColorsFrame(sequence(), colors)); + state.dpiColors = colors; + return colors[index]!; + }); } public async setLiftOffDistance(level: LiftOffLevel): Promise { @@ -662,16 +683,18 @@ export class AmketteXsHidClient { 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); + private writeSensorOptions(patch: Partial>): Promise { + return this.enqueue(async () => { + 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.transmit((sequence) => ryunixSetSensorOptionsFrame(sequence(), next)); + Object.assign(state, next); + }); } public async setAngleSnapping(enabled: boolean): Promise { @@ -694,22 +717,25 @@ export class AmketteXsHidClient { 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.", + const mode = lighting.mode; + return this.enqueue(async () => { + 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.transmit((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 mode; }); - state.brightness = level; - state.speed = speed; - return lighting.mode; } /** @@ -723,12 +749,14 @@ export class AmketteXsHidClient { 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; + return this.enqueue(async () => { + 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.transmit((sequence) => ryunixSetButtonsFrames([sequence(), sequence()], positions)); + state.buttons = changed; + return action; + }); } } From 9c830986f656ce71223bb8427924dc03b4d54a63 Mon Sep 17 00:00:00 2001 From: Kunal Agrawal <66416646+kunalagra@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:08:11 +0530 Subject: [PATCH 4/5] fix(amkette): keep write-only state per mouse, handing it over only on replug Write-only settings (DPI table, sensor options, lift-off, sleep, lighting level, buttons) were keyed by product ID alone, so two identical XS mice on the same connection path shared them: one mouse's status showed the other's writes, and the next write sent it the other mouse's values. Each HIDDevice handle now has its own state. A replugged mouse arrives as a new handle, so a new handle takes over the state of a same-product handle that WebHID reported disconnected, which keeps the session's DPI table across a replug. Any other new handle starts fresh, including a second mouse that is switched to while the first is closed but still plugged in. Co-Authored-By: Claude Opus 5.5 --- docs/amkette-xs-flow-testing.md | 9 ++- src/drivers/amkette/xs-hid.test.ts | 44 ++++++++++++ src/drivers/amkette/xs-hid.ts | 103 +++++++++++++++++++---------- 3 files changed, 118 insertions(+), 38 deletions(-) diff --git a/docs/amkette-xs-flow-testing.md b/docs/amkette-xs-flow-testing.md index 7a84886a..7b2ef8f9 100644 --- a/docs/amkette-xs-flow-testing.md +++ b/docs/amkette-xs-flow-testing.md @@ -228,9 +228,12 @@ Consequences worth knowing: 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; +- what was written is remembered per connection for the session only, so the + same mouse on the cable, the receiver and Bluetooth starts fresh on each path. + A replugged mouse (a new WebHID handle) takes over what was written to a + handle with the same product ID that WebHID reported disconnected; two + identical mice connected together keep their own. Two that are both unplugged + and replugged may swap, since nothing on the wire tells them apart; - 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 diff --git a/src/drivers/amkette/xs-hid.test.ts b/src/drivers/amkette/xs-hid.test.ts index d16fbae8..92b4e79f 100644 --- a/src/drivers/amkette/xs-hid.test.ts +++ b/src/drivers/amkette/xs-hid.test.ts @@ -16,6 +16,15 @@ import { AmketteXsHidClient, isAmketteDevice, resetAmketteXsWriteState } from ". beforeEach(() => resetAmketteXsWriteState()); +/** Stands in for `navigator.hid`, whose disconnect events hand write state to a replugged mouse. */ +const hid = new EventTarget(); +Object.defineProperty(globalThis.navigator, "hid", { value: hid, configurable: true }); + +function unplug(device: HIDDevice): void { + void device.close(); + hid.dispatchEvent(Object.assign(new Event("disconnect"), { device })); +} + function report(reportId: number): HIDReportInfo { return { reportId, items: [] } as unknown as HIDReportInfo; } @@ -526,6 +535,41 @@ test("commands from separate callers are serialised and sequenced", async () => assert.deepEqual(transport.sent.map((entry) => entry.data[0]), [0x05, 0x07, 0x08]); }); +test("two identical mice connected together keep their own written settings", async () => { + const first = await openClient(); + const second = await openClient(); + await first.client.setDpiStageValue(0, 500); + await first.client.setAngleSnapping(true); + const status = await second.client.readStatus(); + assert.notEqual(status.dpiStages?.[0], 500); + assert.equal(status.angleSnapping, false); + await second.client.setDpiStageValue(1, 900); + assert.notEqual((await second.client.readStatus()).dpiStages?.[0], 500, "the second mouse is not sent the first one's table"); + const firstStages = (await first.client.readStatus()).dpiStages; + assert.equal(firstStages?.[0], 500); + assert.notEqual(firstStages?.[1], 900); +}); + +test("switching away from a mouse does not hand its settings to another", async () => { + const first = await openClient(); + await first.client.setAngleSnapping(true); + await first.client.close(); + const second = await openClient(); + assert.equal((await second.client.readStatus()).angleSnapping, false); + await second.client.close(); + assert.equal((await first.client.readStatus()).angleSnapping, true); +}); + +test("a replugged mouse keeps what was written before the unplug", async () => { + const before = await openClient(); + await before.client.setDpiStageValue(0, 500); + unplug(before.transport.device); + const after = await openClient(); + assert.equal((await after.client.readStatus()).dpiStages?.[0], 500); + const other = await openClient(); + assert.notEqual((await other.client.readStatus()).dpiStages?.[0], 500, "only one replugged handle inherits it"); +}); + test("concurrent writes build on each other instead of on the state before either", async () => { const { client } = await openClient(); await Promise.all([client.setAngleSnapping(true), client.setRippleControl(true)]); diff --git a/src/drivers/amkette/xs-hid.ts b/src/drivers/amkette/xs-hid.ts index 4afc2c93..85771ed9 100644 --- a/src/drivers/amkette/xs-hid.ts +++ b/src/drivers/amkette/xs-hid.ts @@ -93,9 +93,9 @@ const VENDOR_DEFAULTS = { } 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. + * What one mouse cannot report back, as last written by OpenMouse this session. + * The cable, the receiver and Bluetooth have their own product IDs, so each + * path starts fresh. */ interface WriteOnlyState { dpiStages: number[] | null; @@ -112,32 +112,65 @@ interface WriteOnlyState { 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, +/** A handle's write-only state, and the handle now using it. */ +interface Claim { + device: HIDDevice; + state: WriteOnlyState; +} + +const claimsByProduct = new Map(); +/** Handles WebHID has reported disconnected; their state can pass to a replugged mouse. */ +const disconnected = new WeakSet(); +let watchedHid: HID | null = null; + +function watchDisconnects(): void { + const hid = globalThis.navigator?.hid; + if (!hid || hid === watchedHid) return; + watchedHid = hid; + hid.addEventListener("disconnect", (event) => disconnected.add(event.device)); +} + +/** + * The state for this handle. WebHID hands a replugged mouse over as a new + * HIDDevice, so a new handle takes over the state of a same-product handle that + * has disconnected, keeping the session's DPI table across the replug. Any + * other new handle starts fresh: two identical mice connected together, or + * switched between, never share settings. Two that are both unplugged and + * replugged may swap, since nothing tells them apart. + */ +function stateFor(device: HIDDevice): WriteOnlyState { + watchDisconnects(); + let claims = claimsByProduct.get(device.productId); + if (!claims) claimsByProduct.set(device.productId, claims = []); + let claim = claims.find((entry) => entry.device === device) + ?? claims.find((entry) => disconnected.has(entry.device)); + if (claim) { + claim.device = device; + } else { + claim = { + device, + 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); + claims.push(claim); } - return state; + return claim.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); + if (productId === undefined) claimsByProduct.clear(); + else claimsByProduct.delete(productId); } function hasCollection(collections: readonly HIDCollectionInfo[], usage: number, featureReport?: number): boolean { @@ -467,7 +500,7 @@ export class AmketteXsHidClient { confirm?: Confirmation, ): Promise { const sensor = this.requireSensor(); - const state = stateFor(this.device.productId); + const state = stateFor(this.device); const table = { stages, activeStage }; await this.transmit((sequence) => [ ryunixSetDpiFrame(sequence(), table, sensor, "x"), @@ -492,7 +525,7 @@ export class AmketteXsHidClient { private buildStatus(): MouseStatus { const telemetry = this.telemetry; - const state = stateFor(this.device.productId); + const state = stateFor(this.device); 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 @@ -586,13 +619,13 @@ export class AmketteXsHidClient { public setDpi(dpi: number): Promise { return this.enqueue(() => { - const stages = this.tableFor(stateFor(this.device.productId)); + const stages = this.tableFor(stateFor(this.device)); return this.writeStageValue(stages, this.activeStage(stages.length), dpi); }); } public setDpiStageValue(stage: number, dpi: number): Promise { - return this.enqueue(() => this.writeStageValue(this.tableFor(stateFor(this.device.productId)), stage, dpi)); + return this.enqueue(() => this.writeStageValue(this.tableFor(stateFor(this.device)), stage, dpi)); } /** Runs inside a queued transaction. */ @@ -615,7 +648,7 @@ export class AmketteXsHidClient { */ public setActiveDpiStage(stage: number): Promise { return this.enqueue(async () => { - const stages = this.tableFor(stateFor(this.device.productId)); + const stages = this.tableFor(stateFor(this.device)); if (!Number.isInteger(stage) || stage < 0 || stage >= stages.length) { throw new RangeError(`Invalid DPI stage ${stage + 1}.`); } @@ -633,7 +666,7 @@ export class AmketteXsHidClient { } return this.enqueue(async () => { const sensor = this.requireSensor(); - const current = this.tableFor(stateFor(this.device.productId)); + const current = this.tableFor(stateFor(this.device)); const stages = ryunixDefaultDpiStages(sensor, RYUNIX_DPI_MAX_STAGES) .map((fallback, index) => current[index] ?? fallback) .slice(0, count); @@ -648,7 +681,7 @@ export class AmketteXsHidClient { throw new RangeError(`Invalid DPI stage ${index + 1}.`); } return this.enqueue(async () => { - const state = stateFor(this.device.productId); + const state = stateFor(this.device); const colors = [...state.dpiColors]; colors[index] = color.toLowerCase(); await this.transmit((sequence) => ryunixSetDpiColorsFrame(sequence(), colors)); @@ -663,7 +696,7 @@ export class AmketteXsHidClient { 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; + stateFor(this.device).liftOffMm = millimetres; return level; } @@ -679,13 +712,13 @@ export class AmketteXsHidClient { wakeOnMove: true, lightOnMove: false, })); - stateFor(this.device.productId).sleepMinutes = minutes; + stateFor(this.device).sleepMinutes = minutes; return minutes * 60; } private writeSensorOptions(patch: Partial>): Promise { return this.enqueue(async () => { - const state = stateFor(this.device.productId); + const state = stateFor(this.device); // The mouse cannot report these, so unwritten ones take the vendor app's defaults. const next = { angleSnapping: patch.angleSnapping ?? state.angleSnapping ?? VENDOR_DEFAULTS.angleSnapping, @@ -719,7 +752,7 @@ export class AmketteXsHidClient { } const mode = lighting.mode; return this.enqueue(async () => { - const state = stateFor(this.device.productId); + const state = stateFor(this.device); const level = lighting.brightness == null ? state.brightness : RYUNIX_BRIGHTNESS_LEVELS[BRIGHTNESS_PERCENT.indexOf(lighting.brightness as 25 | 50 | 75 | 100)]; @@ -750,7 +783,7 @@ export class AmketteXsHidClient { const code = RYUNIX_BUTTON_ACTIONS.get(action); if (!code) throw new Error(`Unknown button action "${action}".`); return this.enqueue(async () => { - const state = stateFor(this.device.productId); + const state = stateFor(this.device); const changed = [...state.buttons]; changed[index] = code; const positions = RYUNIX_BUTTON_FACTORY_CODES.map((factory, position) => changed[position] ?? factory); From 89486fbce4f5534d8b2a99284cd9c67d7b49ab31 Mon Sep 17 00:00:00 2001 From: Kunal Agrawal <66416646+kunalagra@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:44:59 +0530 Subject: [PATCH 5/5] fix(amkette): queue close and commands per handle, and confirm only on fresh status Review follow-ups on the XS driver: - close() runs through the command queue, after the commands already queued. Before, a write still in flight when OpenMouse switched mice reopened the closed device and reattached its listener, and a close during a multi-frame write cut the remaining frames off. - The command queue belongs to the handle, shared by every client on it, now that write-only state does too. Two clients' read-modify-write setters can no longer overwrite each other's options, and their frames cannot interleave around an acknowledgement that carries no sequence. - DPI setters open the mouse and wait for its first status report before reading the sensor type, so a DPI write right after open() no longer fails with "has not reported its sensor yet". - A confirmed write fails only when a report requested after it shows a different value. A mismatch against the report cached before the write is not evidence, so with no fresh report the acknowledgement decides. - The test's navigator stub replaces the global wholesale, as the Wooting tests do, since Node 20 (allowed by engines) has no global navigator. Co-Authored-By: Claude Opus 5.5 --- src/drivers/amkette/xs-hid.test.ts | 38 +++++++++++++++++- src/drivers/amkette/xs-hid.ts | 64 +++++++++++++++++++++--------- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/src/drivers/amkette/xs-hid.test.ts b/src/drivers/amkette/xs-hid.test.ts index 92b4e79f..f7e3fd47 100644 --- a/src/drivers/amkette/xs-hid.test.ts +++ b/src/drivers/amkette/xs-hid.test.ts @@ -18,7 +18,7 @@ beforeEach(() => resetAmketteXsWriteState()); /** Stands in for `navigator.hid`, whose disconnect events hand write state to a replugged mouse. */ const hid = new EventTarget(); -Object.defineProperty(globalThis.navigator, "hid", { value: hid, configurable: true }); +Object.defineProperty(globalThis, "navigator", { value: { hid }, configurable: true }); function unplug(device: HIDDevice): void { void device.close(); @@ -293,6 +293,16 @@ test("startNotifications reports that the mouse does not push status", async () assert.equal(await client.startNotifications(() => {}), false); }); +test("a confirmed write with no fresh report is judged by its acknowledgement", async () => { + const { transport, client } = await openClient(); + await client.readStatus(); + // From here the mouse acknowledges commands but answers no status request. + const receive = transport.device.receiveFeatureReport.bind(transport.device); + transport.device.receiveFeatureReport = async (reportId: number) => + reportId === RYUNIX_CONFIG_REPORT_ID ? new DataView(new ArrayBuffer(31)) : receive(reportId); + assert.equal(await client.setPollingRate(1000), 1000); +}); + 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. @@ -570,6 +580,32 @@ test("a replugged mouse keeps what was written before the unplug", async () => { assert.notEqual((await other.client.readStatus()).dpiStages?.[0], 500, "only one replugged handle inherits it"); }); +test("close waits for queued writes and stays closed", async () => { + const { transport, client } = await openClient(); + const write = client.setAngleSnapping(true); + await client.close(); + await write; + assert.equal(transport.device.opened, false); + assert.equal(transport.listenerAttached(), false); + assert.equal(transport.sent.filter((entry) => entry.data[0] === 0x08).length, 1, "the queued write still went out"); +}); + +test("two clients on one handle share its command queue", async () => { + const { transport, client } = await openClient(); + const other = new AmketteXsHidClient(transport.device); + await Promise.all([client.setAngleSnapping(true), other.setRippleControl(true)]); + const status = await other.readStatus(); + assert.equal(status.angleSnapping, true); + assert.equal(status.rippleControl, true); +}); + +test("a DPI write on a client not yet opened waits for the sensor report", async () => { + const transport = fakeDevice(); + const client = new AmketteXsHidClient(transport.device); + assert.equal(await client.setDpiStageValue(0, 500), 500); + assert.equal((await client.readStatus()).dpiStages?.[0], 500); +}); + test("concurrent writes build on each other instead of on the state before either", async () => { const { client } = await openClient(); await Promise.all([client.setAngleSnapping(true), client.setRippleControl(true)]); diff --git a/src/drivers/amkette/xs-hid.ts b/src/drivers/amkette/xs-hid.ts index 85771ed9..a03fc779 100644 --- a/src/drivers/amkette/xs-hid.ts +++ b/src/drivers/amkette/xs-hid.ts @@ -167,6 +167,13 @@ function stateFor(device: HIDDevice): WriteOnlyState { return claim.state; } +/** + * One command queue per handle, shared by every client on it: write-only state + * is per handle, and the acknowledgement carries no sequence number, so two + * clients' commands must not interleave. + */ +const queues = new WeakMap>(); + /** Forget what this session wrote, e.g. between tests. */ export function resetAmketteXsWriteState(productId?: number): void { if (productId === undefined) claimsByProduct.clear(); @@ -226,7 +233,6 @@ export class AmketteXsHidClient { private openRequestPending = false; private listening = false; private sequence = 0; - private queue: Promise = Promise.resolve(); private telemetryWaiters: Array<() => void> = []; private onChange: (() => void) | null = null; @@ -264,15 +270,21 @@ export class AmketteXsHidClient { } } - 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(); + /** + * Runs after the commands already queued, so a pending write finishes all its + * frames rather than reopening the mouse once it is closed. + */ + public close(): Promise { + return this.enqueue(async () => { + 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(); + }); } /** @@ -410,8 +422,8 @@ export class AmketteXsHidClient { } private enqueue(action: () => Promise): Promise { - const run = this.queue.then(action); - this.queue = run.catch(() => undefined); + const run = (queues.get(this.device) ?? Promise.resolve()).then(action); + queues.set(this.device, run.catch(() => undefined)); return run; } @@ -443,9 +455,9 @@ export class AmketteXsHidClient { /** * 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. + * between the write and its confirmation. Throws when a report requested + * after the write shows a value other than the one written; with no fresh + * report, the write is judged by its acknowledgement alone. */ private send( build: (sequence: () => number) => Uint8Array | Uint8Array[], @@ -470,8 +482,9 @@ export class AmketteXsHidClient { await this.sendFrame(frame); } if (!confirm) return; + const before = this.telemetryCount; const confirmed = await this.confirmByTelemetry(confirm.accept); - if (!confirmed && this.telemetry && !confirm.accept(this.telemetry)) { + if (!confirmed && this.telemetryCount > before && this.telemetry && !confirm.accept(this.telemetry)) { throw new Error(confirm.mismatch(this.telemetry)); } } @@ -493,6 +506,15 @@ export class AmketteXsHidClient { return state.dpiStages ? [...state.dpiStages] : ryunixDefaultDpiStages(ryunixSensor(this.telemetry?.sensorType ?? null)); } + /** + * Opens the mouse and, until it has reported, waits for the report that names + * its sensor, which DPI values depend on. Runs inside a queued transaction. + */ + private async awaitSensorReport(): Promise { + await this.open(); + if (!this.telemetry) await this.refreshTelemetry(); + } + /** Runs inside a queued transaction. */ private async writeDpiTable( stages: number[], @@ -618,14 +640,18 @@ export class AmketteXsHidClient { } public setDpi(dpi: number): Promise { - return this.enqueue(() => { + return this.enqueue(async () => { + await this.awaitSensorReport(); const stages = this.tableFor(stateFor(this.device)); return this.writeStageValue(stages, this.activeStage(stages.length), dpi); }); } public setDpiStageValue(stage: number, dpi: number): Promise { - return this.enqueue(() => this.writeStageValue(this.tableFor(stateFor(this.device)), stage, dpi)); + return this.enqueue(async () => { + await this.awaitSensorReport(); + return this.writeStageValue(this.tableFor(stateFor(this.device)), stage, dpi); + }); } /** Runs inside a queued transaction. */ @@ -648,6 +674,7 @@ export class AmketteXsHidClient { */ public setActiveDpiStage(stage: number): Promise { return this.enqueue(async () => { + await this.awaitSensorReport(); const stages = this.tableFor(stateFor(this.device)); if (!Number.isInteger(stage) || stage < 0 || stage >= stages.length) { throw new RangeError(`Invalid DPI stage ${stage + 1}.`); @@ -665,6 +692,7 @@ export class AmketteXsHidClient { throw new RangeError(`A DPI table needs 1 to ${RYUNIX_DPI_MAX_STAGES} stages.`); } return this.enqueue(async () => { + await this.awaitSensorReport(); const sensor = this.requireSensor(); const current = this.tableFor(stateFor(this.device)); const stages = ryunixDefaultDpiStages(sensor, RYUNIX_DPI_MAX_STAGES)