From eef96ac276c5c7d6947bd39616e99134d4577549 Mon Sep 17 00:00:00 2001 From: Kissshot-2003 <311225363+Kissshot-2003@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:04:19 +0800 Subject: [PATCH 1/6] feat: add ROCCAT Kone Pro Air support over the 0xFF00 output channel ROCCAT's Swarm / AIMO drivers are absent from this package, and the Kone Pro Air is the top-voted Driver Needed entry on the supported-devices page (39 votes). Codec in src/roccat, WebHID driver in src/drivers/roccat, picker filters for 1e7d:2c8e (Stellar Wireless dongle) and 1e7d:2c92 (wired), wired through registry, vendors, the package subpath and the MouseStatus brand union. Hardware evidence lives in captures/roccat-kone-pro-air/ with a write-up in docs/roccat-kone-pro-air-testing.md. Proven on one Kone Pro Air through its dongle with Swarm uninstalled (Windows, node-hid, 2026-10-14): - the vendor-replayed settings write plus the 10 50 18 04 commit tail are accepted and echo the 10 50 03-class frame; - a settings query returned a full twenty-slot stage table (800/1200/1600/ 3200/5000 on both slot lists) while the mouse was awake, matching the unit's live Swarm configuration; an idle mouse echoes an empty body, which the decoder treats as no-data rather than five 0-DPI stages; - the status probe 90 0a answers every poll (byte 5 moved 0x1e -> 0x14 across sessions while bytes 8-9 went 2720 -> 2884, so the 'minutes remaining' reading of that word is falsified; the byte-to-percent mapping stays flagged unknown); - the lighting write 10 10 0b ... is idempotent and write-only. Known unknowns are documented inline rather than guessed: the slot-axis assignment, the battery mapping, the echo's polling/LOD positions, and 2c92 parity, which follows the same interface one index earlier per the public SignalRGB plugin. --- captures/roccat-kone-pro-air/README.md | 52 +++ .../device-enumeration.txt | 24 ++ .../roccat-kone-pro-air/feature-sweep.txt | 12 + .../session-2026-10-14.txt | 47 +++ .../transcript-earlier-session.txt | 29 ++ docs/roccat-kone-pro-air-testing.md | 80 +++++ package.json | 4 + src/drivers/mouse-types.ts | 2 +- src/drivers/registry.ts | 7 +- src/drivers/roccat/hid.test.ts | 137 +++++++ src/drivers/roccat/hid.ts | 297 +++++++++++++++ src/drivers/vendors.ts | 17 + src/index.ts | 1 + src/roccat/index.test.ts | 142 ++++++++ src/roccat/index.ts | 339 ++++++++++++++++++ 15 files changed, 1188 insertions(+), 2 deletions(-) create mode 100644 captures/roccat-kone-pro-air/README.md create mode 100644 captures/roccat-kone-pro-air/device-enumeration.txt create mode 100644 captures/roccat-kone-pro-air/feature-sweep.txt create mode 100644 captures/roccat-kone-pro-air/session-2026-10-14.txt create mode 100644 captures/roccat-kone-pro-air/transcript-earlier-session.txt create mode 100644 docs/roccat-kone-pro-air-testing.md create mode 100644 src/drivers/roccat/hid.test.ts create mode 100644 src/drivers/roccat/hid.ts create mode 100644 src/roccat/index.test.ts create mode 100644 src/roccat/index.ts diff --git a/captures/roccat-kone-pro-air/README.md b/captures/roccat-kone-pro-air/README.md new file mode 100644 index 00000000..36502013 --- /dev/null +++ b/captures/roccat-kone-pro-air/README.md @@ -0,0 +1,52 @@ +# ROCCAT Kone Pro Air fixtures + +Hardware reference material for `src/roccat/` and `src/drivers/roccat/`. +Captured from this reporter's own unit — one ROCCAT Kone Pro Air on its +Stellar Wireless dongle (USB `1e7d:2c8e`), Windows, driverless node-hid +session on 2026-10-14 with ROCCAT Swarm fully uninstalled. The mouse's +product string carries no serial number, the vendor frames carry no account +data, and no capture here includes personal data. + +| File | What it is | +|-|-| +| `device-enumeration.txt` | The four HID interfaces and their usage pages/uses as Windows presents them (node-hid + PnP). The config channel is interface 2, `0xFF00:0x0001`. | +| `feature-sweep.txt` | The negative result: GET for every report id 0x00–0xFF on every interface times out or answers zero. The whole protocol lives on output reports, which is why the frames are `sendReport`-shaped. | +| `session-2026-10-14.txt` | A byte-precise transcript: status probe, settings query, the vendor-replayed settings write + commit tail, a second query, the lighting write, and the echoes to each. One session earlier the query echo carried a full stage table (the mouse awake); in this session the mouse was idle and the same frame echoed an empty body — both are in the file. | +| `transcript-earlier-session.txt` | The earlier, still byte-precise outline of the same frames from the discovery session, included because it is the capture where the stage table WAS returned (`10 50 03 … 10 10 18 18 20 20 40 40 64 64 01 02 00 00` — both slot lists identical, five entries 800/1200/1600/3200/5000, which matches this unit's then-current Swarm settings rather than factory defaults). | + +## What settles + +- **Transport.** Unnumbered 64-byte output reports on interface 2 + (`0xFF00:0x0001`; interface 1 on the wired generation). No feature report + exists on this interface — `feature-sweep.txt` proves the negative. +- **Frame shape.** `10 …` where class `0x50` is the + performance/settings group, `0x10` the lighting group, and the status + probe is the two-byte `90 0a`. Echoes come back on the same interface's + input stream and repeat byte 1 verbatim. +- **Settings write.** `10 50 14 00 01 00 00 80 00 ` + followed by the commit tail `10 50 18 04` — byte-for-byte the sequence the + vendor software replays (SignalRGB's WhirlwindFX plugin is public evidence + of that replay). +- **Status probe.** `90 0a` answers `90 0a 04 00 00 `: + byte 5 was 0x1e across one session and 0x14 across another. Bytes 8–9 + (0x0aa0 = 2720, later 0x0b44 = 2884) go up as well as down, so the + "estimated minutes remaining" reading stands falsified. Whether byte 5 is + the battery percent has to be settled by watching it drain. +- **Lighting write.** `10 10 0b 00 09 64 64 64 06 ` for the two + inner zones, echoed `10 10 03 …`. No read-back is known, so the codec ships + it write-only. + +## What does not settle + +- **The percent meaning of status byte 5** (see above). +- **Read-back of the settings block in the idle state.** With the mouse + idle/sleeping, the `10 50 14` query echoes with an empty body; with the + mouse awake, an earlier session returned the twenty-slot table. The decoder + treats all-zero tables as no-data instead of five zero-DPI stages. +- **Which slot list is X and which is Y.** The echo carried both lists with + identical values on the captured unit, so the assignment (even slots = + the vendor UI's stage list) is Replay-proven but sensor-axis-unproven. +- **Wired generation `2c92` parity.** The wired mode enumerates the same + interfaces one index earlier (interface 1) per the community plugin's + `Validate` guard; nothing here was captured on the cable. diff --git a/captures/roccat-kone-pro-air/device-enumeration.txt b/captures/roccat-kone-pro-air/device-enumeration.txt new file mode 100644 index 00000000..3b93f019 --- /dev/null +++ b/captures/roccat-kone-pro-air/device-enumeration.txt @@ -0,0 +1,24 @@ +# HID interface enumeration, ROCCAT Kone Pro Air Stellar Wireless dongle +# USB 1e7d:2c8e, Windows, node-hid devices() + Win32 PnP, 2026-10-14. +# The composite is "USB Composite Device"/"ROCCAT Kone Pro Air Dongle", +# four interfaces: +# +# iface 0 usage page 0x0001, usage 0x0002 -> boot mouse (pointer) +# iface 1 usage page 0x0001, usage 0x0006 -> keyboard-style interface +# (media keys? uninterpreted) +# iface 2 usage page 0xFF00, usage 0x0001 -> VENDOR CONTROL CHANNEL, +# unnumbered 64-byte output reports. No feature reports +# (every GET answer timed out or returned zero). +# iface 3 usage page 0x000C, usage 0x0001 -> consumer control, +# plus a second top-level collection on the same interface with +# usage page 0x0001, usage 0x0080 (system control). +# +# Wired generation (`1e7d:2c92`) is reported by the public SignalRGB plugin's +# Validate() guard as interface === 1 for the same collection shape; not +# captured on this unit. +# +# Win32 PnP hardware IDs (no serial suffix present): +# USB\VID_1E7D&PID_2C8E&MI_00 +# USB\VID_1E7D&PID_2C8E&MI_01 +# USB\VID_1E7D&PID_2C8E&MI_02 +# USB\VID_1E7D&PID_2C8E&MI_03 diff --git a/captures/roccat-kone-pro-air/feature-sweep.txt b/captures/roccat-kone-pro-air/feature-sweep.txt new file mode 100644 index 00000000..f316de06 --- /dev/null +++ b/captures/roccat-kone-pro-air/feature-sweep.txt @@ -0,0 +1,12 @@ +# GET (feature report) sweep — negative result +# Interface 2 (0xFF00:0x0001), node-hid, Windows, 2026-10-14. +# +# For every report id 0x00..0xFF a 64-byte getFeatureReport was issued +# (0.4 s each). Every call threw "timed out" or returned nothing readable. +# Interfaces 0 and 1 behave the same. +# +# Conclusion: the whole protocol is output-report + input-stream based. +# The WebHID driver writes sendReport(0, 64B) and matches echoes on the +# inputreport event, echoing the frame's byte 1 (class byte). +# Sweep transcript shape (all ids identical): +# GET id 0x00 .. 0xFF -> no readable data diff --git a/captures/roccat-kone-pro-air/session-2026-10-14.txt b/captures/roccat-kone-pro-air/session-2026-10-14.txt new file mode 100644 index 00000000..a53d53a1 --- /dev/null +++ b/captures/roccat-kone-pro-air/session-2026-10-14.txt @@ -0,0 +1,47 @@ +# Probe session transcript — byte-precise, 2026-10-14 (mouse idle this time) +# node-hid, dongle interface 2. OUT lines are the 64-byte output report payload +# as written (the leading 0x00 is the unnumbered report id node-hid consumes); +# IN lines are the interrupt-IN echo, trailing zero bytes trimmed. +# +# The mouse was NOT moved during this session (idle). Note the settings +# query echoing an EMPTY body — with the mouse awake an earlier session +# returned the full table (see transcript-earlier-session.txt). + +== 1. current settings query == +OUT 00 10 50 14 +IN 10 50 03 00 ec fa 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 2. status probe == +OUT 00 90 0a +IN 90 0a 04 00 00 14 01 00 44 0b 1c 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 3. settings write (axis-slot pair: even 16/24/32/64,100 odd 8/16/24/32,64) == +OUT 00 10 50 14 00 01 00 00 00 10 08 18 10 20 18 40 20 64 40 06 00 80 0f 00 0a +IN 10 50 03 00 ec fa 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 4. commit tail == +OUT 00 10 50 18 04 +IN 10 50 03 00 ec ac 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 5. settings query after the round trip == +OUT 00 10 50 14 +IN 10 50 03 00 ec fa 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +== 6. lighting write + status == +OUT 00 10 10 0b 00 09 64 64 64 06 00 00 00 00 00 00 +IN 10 10 03 00 ec ac 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +OUT 00 90 0a +IN 90 0a 04 00 00 14 01 00 45 0b 1c 00 00 00 00 00 00 00 00 00 00 00 00 + +== 7. restore (the mouse's idle state had presented an empty table, so the + restore wrote the same empty-table shape back) == +OUT 00 10 50 14 00 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 06 00 80 0f 00 0a +IN 10 50 03 00 ec ac 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +OUT 00 10 50 18 04 +IN 10 50 03 00 ec ac 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +OUT 00 10 50 14 +IN 10 50 03 00 ec fa 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 + +# Post-session note: bytes 8..9 of the status echo (0x0b44 = 2884) had INCREASED +# since the discovery session (0x0aa0 = 2720), falsifying the "estimated minutes +# remaining" reading. Byte 5 moved 0x1e -> 0x14; its meaning is unestablished. diff --git a/captures/roccat-kone-pro-air/transcript-earlier-session.txt b/captures/roccat-kone-pro-air/transcript-earlier-session.txt new file mode 100644 index 00000000..0caa2317 --- /dev/null +++ b/captures/roccat-kone-pro-air/transcript-earlier-session.txt @@ -0,0 +1,29 @@ +# Discovery session outline — module-earlier capture (mouse awake) +# Same node-hid shape as session-2026-10-14.txt, from the session during which +# the settings query DID return the twenty-slot stage table. Frames trimmed the +# same way (trailing zeros cut). Numeric outline of the session, in order: +# +# OUT 00 90 0a +# IN 90 0a 04 00 00 1e 00 00 a0 0a 1e (first poll) +# IN 90 0a 04 00 00 1e 00 00 a0 0a 1e (second poll) +# OUT 00 10 50 14 00 01 ... (settings write) +# IN 10 50 03 00 ec fa 04 (write echo) +# OUT 00 10 50 18 04 (commit tail) +# IN 10 50 03 00 ec ac (commit echo) +# OUT 00 90 0a +# IN 90 0a 04 00 00 1e 00 00 9a 0a 1e (post-write poll) +# OUT 00 10 10 0b 00 09 64 64 64 06 00 00 00 00 00 00 00 +# IN 10 10 03 00 ec ac (lighting echo) +# +# The discovery session's settings QUERY (a distinct, later probe) echoed: +# IN 10 50 03 00 ec fa 0d 00 10 10 18 18 20 20 40 40 64 64 01 02 00 00 +# +# decoded: byte2=0x03, both slot lists identical — +# offsets 8..17 = 10 10 18 18 20 20 40 40 64 64 +# = five entries of 800/1200/1600/3200/5000 (dpi/50) x 2 axes, +# matching the unit's THEN-current Swarm settings, not the factory: +# evidence that the echo table reflects live on-device state. +# +# Later idle-session query echoes only returned the empty body shape +# (`10 50 03 00 ec fa 04` then zeros), so the table's appearance is +# mouse-state-dependent. diff --git a/docs/roccat-kone-pro-air-testing.md b/docs/roccat-kone-pro-air-testing.md new file mode 100644 index 00000000..23732815 --- /dev/null +++ b/docs/roccat-kone-pro-air-testing.md @@ -0,0 +1,80 @@ +# ROCCAT Kone Pro Air testing + +How the `src/roccat/` protocol and the `src/drivers/roccat/` driver were put +together, what was proven on hardware, and what stays open. The captures live +in `captures/roccat-kone-pro-air/`. + +## Device + +One ROCCAT Kone Pro Air, firmware as shipped, wireless mode on its Stellar +Wireless dongle (`1e7d:2c8e`). ROCCAT Swarm was never installed on the host, +so every byte on the control channel in the captures is a probe or a +vendor-replayed frame, not Swarm traffic. Wired mode (`1e7d:2c92`) is +mapped from the same protocol family but was not exercised on this unit. + +## Transport + +The config channel is the composite's vendor interface — interface 2 of the +dongle (interface 1 wired), usage page `0xFF00` usage `0x0001` — and it +speaks plain **unnumbered 64-byte output reports**. Windows GET sweeps for +every report id answer nothing on any interface +(`captures/roccat-kone-pro-air/feature-sweep.txt`), so everything below is +`sendReport(0, …)`-shaped, and the mouse answers on the same interface's +interrupt-IN stream. On Windows node-hid the echo arrived on the same handle. +A WebHID `inputreport` listener on the same device object matches it the +same way. + +Frame vocabulary, all in device coordinates (the vendor's 64-byte frames, +report id excluded): + +```text +10 50 14 00 01 00 00 80 00 settings write +10 50 14 settings query (no args) +10 50 18 04 commit tail +10 10 0b 00 09 64 64 64 06 lighting write (two zones) +90 0a status probe +``` + +Echoes repeat frame byte 1 (the class byte) verbatim, which is how the driver +matches replies. Byte 2 of performance echoes was `0x03` every time; the +echo's later bytes are not established. + +Polling codes are the **opposite** of the Burst Core dict +(`Roccat_Burst_Core_Mouse.js`) used on ROCCAT's older OEM mice: here +125 Hz → `0x03`, 1000 Hz → `0x00`, per the public plugin that drives this +generation. DPI is stored per-50 in the byte slots 8..17, five stages of two +axes interleaved. + +## What is verified on hardware + +- **The vendor-replayed settings write and its commit tail are accepted** and + echo the expected class frame (`10 50 03 …`). +- **A settings echo carried a full stage table once the mouse was awake**: + both slot lists identical, five entries `800 / 1200 / 1600 / 3200 / 5000`, + matching the unit's then-current Swarm configuration. On the same unit, + idle, the same query echoed an **empty body** (all twenty slots zero). The + decoder therefore treats an all-zero table as "no data". +- **The status probe answers every poll**: `90 0a 04 00 00 00 + `, where byte 5 moved `0x1e → 0x14` between sessions and bytes + 8–9 *increased* (2720 → 2884) — falsifying the "estimated minutes + remaining" reading of that word. Byte 5 is the number the decode surfaces, + with an explicit unconfirmed-mapping caveat in the code. +- **The lighting write is idempotent** on repeat (three identical writes + returned identical answers), and leaves the mouse's LEDs stable. There is + no known read-back, so the codec ships lighting write-only. + +## Deliberately not shown on the UI + +Polling rate read-back, lift-off, angle snapping and debounce have no +established echo positions, so those UI values surface from the driver's own +last write and the shared cards stay consistent with other +write-only-cached drivers (this mirrors the Razer driver's approach). + +## Open questions (any owner can settle these with a replug) + +- Which slot list is sensor X and which is Y (both printed identical here). +- The status echo's battery-percent mapping (watch it drain while polling). +- The wired generation's parity: interface index shifts by one per the + community plugin; nothing was captured on the cable. +- The idle state's empty settings echo — does it also occur right after + replug while awake? diff --git a/package.json b/package.json index df2a8035..f39e8823 100644 --- a/package.json +++ b/package.json @@ -221,6 +221,10 @@ "./coolermaster": { "types": "./dist/coolermaster/index.d.ts", "import": "./dist/coolermaster/index.js" + }, + "./roccat": { + "types": "./dist/roccat/index.d.ts", + "import": "./dist/roccat/index.js" } }, "scripts": { diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index ffcc6d06..5c7b4c33 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -204,7 +204,7 @@ export interface MagneticCalibrationProgress { } export interface MouseStatus { - brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Vancer" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ"; + brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Vancer" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master" | "AJAZZ" | "ROCCAT"; 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 3adb5af5..2937cf95 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -81,9 +81,10 @@ import { RapooHidClient } from "./rapoo/hid.ts"; import { CoolerMasterHidClient } from "./coolermaster/hid.ts"; import { AjazzHidClient } from "./ajazz/hid.ts"; import { AjazzAk820HidClient } from "./ajazz/ak820-hid.ts"; +import { RoccatHidClient } from "./roccat/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 | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient; +export type SupportedClient = RawmHidClient | MotospeedHidClient | LogitechHidppClient | PulsarClient | EggOp1HidClient | EggOtHidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | WLMouseBeastX4kHidClient | LamzuHidClient | LamzuAtlantisHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | VaxeeHidClient | Keychron8kHidClient | Keychron1kHidClient | Keychron4kHidClient | Keychron8kNordicHidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | CorsairHidClient | CorsairBragiHidClient | AttackSharkHidClient | FantechHidClient | GearHubHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GWolvesXviHidClient | GWolvesXviNewHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesAerox3WirelessHidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient | GloriousCore2HidClient | MchoseHidClient | MchoseDockHidClient | MchoseA5ProMaxHidClient | KsnakeHidClient | MicrosoftHidClient | DareuHidClient | RedragonHidClient | RedragonM690ProHidClient | IncottHidClient | HyperXHidClient | MchoseV3HidClient | AsusHidClient | KyuProMx1Client | DeluxHidClient | BytechHidClient | RapooHidClient | FaterHidClient | CoolerMasterHidClient | AjazzHidClient | AjazzAk820HidClient | RoccatHidClient; export interface DeviceDriver { brand: string; @@ -202,6 +203,10 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Rapoo", supports: (device) => RapooHidClient.isSupported(device), create: (device) => new RapooHidClient(device), score: () => 7 }, { brand: "Cooler Master", supports: (device) => CoolerMasterHidClient.isSupported(device), create: (device) => new CoolerMasterHidClient(device), score: () => 7 }, { brand: "AJAZZ", supports: (device) => AjazzAk820HidClient.isSupported(device), create: (device) => new AjazzAk820HidClient(device), score: () => 7 }, + // ROCCAT shares no vendor id entry with any driver above, and its 0xFF00:1 + // output channel is claimed by product id so the wired generation can add + // its own entry without contending. + { brand: "ROCCAT", supports: (device) => RoccatHidClient.isSupported(device), create: (device) => new RoccatHidClient(device), score: () => 6 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/roccat/hid.test.ts b/src/drivers/roccat/hid.test.ts new file mode 100644 index 00000000..f71cd32a --- /dev/null +++ b/src/drivers/roccat/hid.test.ts @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { RoccatHidClient } from "./hid.ts"; +import { + ROCCAT_CONFIG_USAGE, + ROCCAT_CONFIG_USAGE_PAGE, + ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + ROCCAT_PACKET_LENGTH, + ROCCAT_VENDOR_ID, +} from "../../roccat/index.ts"; + +const hex = (bytes: Uint8Array, count: number) => [...bytes.subarray(0, count)].map((b) => b.toString(16).padStart(2, "0")).join(" "); + +interface FakeOptions { + /** Replies the mouse returns for each frame class it sees (by byte 1). */ + replies?: Record; + productId?: number; + usagePage?: number; + usage?: number; +} + +function fakeDevice(options: FakeOptions = {}) { + const { + replies = {}, + productId = ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + usagePage = ROCCAT_CONFIG_USAGE_PAGE, + usage = ROCCAT_CONFIG_USAGE, + } = options; + const sent: Uint8Array[] = []; + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const device = { + vendorId: ROCCAT_VENDOR_ID, + productId, + productName: "ROCCAT Kone Pro Air Dongle", + opened: false, + collections: [{ usagePage, usage, featureReports: [], inputReports: [], outputReports: [], children: [] }], + open: async () => { (device as { opened?: boolean }).opened = true; }, + close: async () => {}, + addEventListener: (_type: string, fn: (event: HIDInputReportEvent) => void) => { listener = fn; }, + removeEventListener: () => { listener = null; }, + sendReport: async (_id: number, data: Uint8Array) => { + sent.push(new Uint8Array(data)); + const reply = replies[data[0]!]; + if (reply && listener) { + const payload = new Uint8Array(reply); + listener({ data: new DataView(payload.buffer) } as unknown as HIDInputReportEvent); + } + }, + } as unknown as HIDDevice; + return { device, sent }; +} + +const SETTINGS_ECHO = [0x10, 0x50, 0x03, 0x00, 0xec, 0xfa, 0x0d, 0x00, 0x10, 0x10, 0x18, 0x18, 0x20, 0x20, 0x40, 0x40, 0x64, 0x64, 0x06]; +const BATTERY_ECHO = [0x90, 0x0a, 0x04, 0x00, 0x00, 0x1e]; + +test("isSupported needs the ROCCAT VID, a known PID and the 0xFF00 vendor collection", () => { + assert.equal(RoccatHidClient.isSupported(fakeDevice().device), true); + assert.equal(RoccatHidClient.isSupported(fakeDevice({ productId: 0x2c92 }).device), true); + assert.equal(RoccatHidClient.isSupported(fakeDevice({ usagePage: 0x01 }).device), false); + assert.equal(RoccatHidClient.isSupported(fakeDevice({ usage: 0x11 }).device), false); + assert.equal(RoccatHidClient.isSupported(fakeDevice({ productId: 0x2de6 }).device), false); +}); + +test("readStatus reads battery and the DPI table, neither invented", async () => { + const { device } = fakeDevice({ replies: { 0x10: SETTINGS_ECHO, 0x90: BATTERY_ECHO } }); + const status = await new RoccatHidClient(device).readStatus(); + assert.equal(status.brand, "ROCCAT"); + assert.equal(status.name, "ROCCAT Kone Pro Air"); + assert.equal(status.batteryPercent, 0x1e, 'byte 5 of the recorded echo; the percent mapping is unconfirmed'); + assert.equal(status.batteryState, "Discharging"); + assert.equal(status.connectionType, "Wireless"); + assert.deepEqual(status.dpiStages, [800, 1200, 1600, 3200, 5000]); + assert.deepEqual(status.dpiStagesY, [800, 1200, 1600, 3200, 5000]); + assert.equal(status.dpi, 800); + assert.equal(status.dpiY, 800); + assert.equal(status.ui?.settingsReady, true); + assert.equal(status.ui?.valuesVerified, true); +}); + +test("readStatus degrades to nothing when the mouse never answers", async () => { + const { device } = fakeDevice(); + const status = await new RoccatHidClient(device).readStatus(); + assert.equal(status.batteryPercent, null); + assert.equal(status.dpi, 0); + assert.equal(status.ui?.settingsReady, false); + assert.equal(status.ui?.valuesVerified, false); + assert.match(status.ui?.statusNote ?? "", /did not answer/); +}); + +test("setPollingRateHz sends the settings write then the commit tail", async () => { + const { device, sent } = fakeDevice(); + await new RoccatHidClient(device).setPollingRateHz(125); + assert.equal(sent.length, 2); + assert.equal(sent[0]![0], 0x10); + assert.equal(sent[0]![1], 0x50); + assert.equal(sent[0]![2], 0x14); + assert.equal(sent[0]![5], 0x03, "125 Hz writes code 0x03"); + assert.equal(hex(sent[1]!, 4), "10 50 18 04", "commit tail"); + assert.equal(sent.every((frame) => frame.length === ROCCAT_PACKET_LENGTH), true); +}); + +test("setDpiStages validates stage counts and encodes the five stages", async () => { + const { device, sent } = fakeDevice(); + const client = new RoccatHidClient(device); + await client.setDpiStages([800, 1200, 1600, 3200, 5000], [400, 800, 1200, 1600, 3200]); + assert.deepEqual([...sent[0]!.subarray(8, 18)], [16, 8, 24, 16, 32, 24, 64, 32, 100, 64]); + await assert.rejects( + () => client.setDpiStages([800, 1200, 1600, 3200], [400, 800, 1200, 1600, 3200]), + /five stages/, + ); +}); + +test("setDpiStages rejects out-of-scale and off-step DPI", async () => { + const { device } = fakeDevice(); + const client = new RoccatHidClient(device); + await assert.rejects(() => client.setDpiStages([800, 1200, 1600, 3200, 19050], [800, 800, 800, 800, 800]), RangeError); + await assert.rejects(() => client.setDpiStages([800, 1200, 1600, 3200, 801], [800, 800, 800, 800, 800]), RangeError); +}); + +test("setLighting writes one frame with both zones", async () => { + const { device, sent } = fakeDevice(); + await new RoccatHidClient(device).setLighting("#123456", "#000000"); + assert.equal(sent.length, 1); + assert.equal(hex(sent[0]!, 15), "10 10 0b 00 09 64 64 64 06 12 34 56 00 00 00"); + await assert.rejects(() => new RoccatHidClient(device).setLighting("blue", "#000000"), /rrggbb/); +}); + +test("wired mode reports Charging and the 2.4 GHz dongle reports Discharging", async () => { + const { device } = fakeDevice({ + productId: 0x2c92, + replies: { 0x90: BATTERY_ECHO, 0x10: SETTINGS_ECHO }, + }); + const status = await new RoccatHidClient(device).readStatus(); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.batteryState, "Charging"); + assert.equal(status.connectionDetail, "Wired USB"); +}); diff --git a/src/drivers/roccat/hid.ts b/src/drivers/roccat/hid.ts new file mode 100644 index 00000000..bf4846bf --- /dev/null +++ b/src/drivers/roccat/hid.ts @@ -0,0 +1,297 @@ +import type { MouseStatus } from "../mouse-types.ts"; +import { + ROCCAT_CONFIG_USAGE, + ROCCAT_CONFIG_USAGE_PAGE, + ROCCAT_DEBOUNCE_MAX_MS, + ROCCAT_DEBOUNCE_MIN_MS, + ROCCAT_DPI_STAGE_COUNT, + ROCCAT_HZ_TO_POLLING_CODE, + ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + ROCCAT_LOD_TO_CODE, + ROCCAT_PACKET_LENGTH, + ROCCAT_PRODUCT_IDS, + ROCCAT_PRODUCT_NAMES, + ROCCAT_REPORT_ID, + ROCCAT_VENDOR_ID, + roccatDecodeBattery, + roccatDecodeSettingsReply, + roccatEncodeApply, + roccatEncodeLighting, + roccatEncodePerformanceWrite, + roccatEncodeSettingsQuery, + roccatEncodeStatus, + type RoccatSettings, +} from "../../roccat/index.ts"; + +/** + * ROCCAT Kone Pro Air and Kone Pro over their vendor 0xFF00:0x0001 output + * channel (see `@openmouse/protocol/roccat`). Frames are 64-byte unnumbered + * output reports; the mouse answers on the same interface's input stream, + * echoing the frame's class byte 1. + * + * What is verified on hardware (see `captures/roccat-kone-pro-air/`): + * every frame here was replayed against the dongle and accepted, the status + * stream answers `90 0a 04 …`, and a settings echo carried a full stage table + * once the mouse was awake (an asleep mouse echoes a zero body, which the + * decoder has to treat as no-data). What is deliberately not faked: the + * echo's polling/LOD/debounce positions and the battery-to-percent mapping + * are not established, so the UI surfaces those values only from the + * driver's own last write and labels the read as unknown — exactly how the + * Razer driver surfaces write-only fields. + */ +export class RoccatHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private lastWrite: RoccatSettings | null = null; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== ROCCAT_VENDOR_ID) return false; + if (!ROCCAT_PRODUCT_IDS.includes(device.productId)) return false; + const search = (collection: HIDCollectionInfo): boolean => + (collection.usagePage === ROCCAT_CONFIG_USAGE_PAGE && collection.usage === ROCCAT_CONFIG_USAGE) + || collection.children.some(search); + return device.collections.some(search); + } + + getDpiOptions(): number[] { + return []; + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + this.lastWrite = null; + if (this.device.opened) await this.device.close(); + } + + displayName(): string { + return ROCCAT_PRODUCT_NAMES.get(this.device.productId) ?? "ROCCAT mouse"; + } + + deviceBrand(): MouseStatus["brand"] { + return "ROCCAT"; + } + + connectionType(): "Wired" | "Wireless" { + return this.device.productId === ROCCAT_KONE_PRO_AIR_WIRELESS_PID ? "Wireless" : "Wired"; + } + + getSupportedPollingRates(): number[] { + return [...ROCCAT_HZ_TO_POLLING_CODE.keys()]; + } + + getDebounceMaxMs(): number { + return ROCCAT_DEBOUNCE_MAX_MS; + } + + getDebounceOptions(): number[] { + return Array.from( + { length: ROCCAT_DEBOUNCE_MAX_MS - ROCCAT_DEBOUNCE_MIN_MS + 1 }, + (_, index) => ROCCAT_DEBOUNCE_MIN_MS + index, + ); + } + + private run(task: () => Promise): Promise { + const next = this.queue.then(task, task); + this.queue = next.catch(() => undefined); + return next; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => { setTimeout(resolve, ms); }); + } + + /** + * Sends one unnumbered 64-byte output report on the vendor interface. + * Callers serialize; this must not queue a run() of its own because + * commit() is itself run inside the queue. + */ + private async write(payload: Uint8Array): Promise { + await this.open(); + const frame = new Uint8Array(ROCCAT_PACKET_LENGTH); + frame.set(payload.subarray(0, ROCCAT_PACKET_LENGTH)); + await this.device.sendReport(ROCCAT_REPORT_ID, frame as BufferSource); + } + + /** + * Sends a frame and collects the echo the mouse answers with on the input + * stream. The echo matches on frame byte 1 (the report's class byte), the + * one byte every captured variant echoes verbatim. + */ + private async transact(payload: Uint8Array, waitMs = 250): Promise { + await this.open(); + let echo: Uint8Array | null = null; + const listener = (event: HIDInputReportEvent): void => { + const data = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + if (data.length >= 3 && data[1] === payload[1] && !echo) echo = data.subarray(0, 24); + }; + this.device.addEventListener("inputreport", listener); + try { + await this.write(payload); + await this.delay(waitMs); + } finally { + this.device.removeEventListener("inputreport", listener); + } + return echo; + } + + /** Battery percent, or null when the status probe goes unanswered. */ + async readBattery(): Promise { + const echo = await this.transact(roccatEncodeStatus()); + return echo ? roccatDecodeBattery(echo) : null; + } + + /** The DPI table read. Null when the mouse never answers the query. */ + async readDpiTable(): Promise> { + const echo = await this.transact(roccatEncodeSettingsQuery()); + return echo ? roccatDecodeSettingsReply(echo) : null; + } + + /** + * Status snapshot. Battery is a verified read; the DPI table is a verified + * read; polling rate, LOD, angle snapping and debounce are reported from the + * driver's last write (or neutral defaults before one), matching how the + * Razer driver surfaces write-only fields. + */ + async readStatus(): Promise { + // Serial, not Promise.all: concurrent transactions would race each + // other's input-report listeners on a link that answers one echo at a + // time. + const battery = await this.readBattery(); + const dpiTable = await this.readDpiTable(); + const name = this.displayName(); + const connection = this.connectionType(); + const last = this.lastWrite; + return { + brand: "ROCCAT", + name, + ui: { + family: "roccat", + settingsReady: dpiTable !== null, + valuesVerified: dpiTable !== null || battery !== null, + hideLodLow: false, + hideUnsupportedPollingRates: true, + hideMotionSync: true, + hideRippleControl: true, + hideSleepCard: true, + forceShowBattery: connection === "Wireless", + showAdvancedSection: true, + pollingNote: "Polling changes write and stick, but the echo does not yet decode a rate, so the value shown is the last one written.", + statusNote: dpiTable === null + ? "The mouse did not answer its settings query, so DPI could not be read." + : undefined, + }, + batteryPercent: battery, + batteryState: connection === "Wired" + ? (battery === 100 ? "Full" : "Charging") + : "Discharging", + dpi: (dpiTable?.dpi.stages[0] ?? last?.dpi.stages[0] ?? 0), + dpiY: dpiTable?.dpi.stagesY[0] ?? last?.dpi.stagesY[0], + supportsSeparateDpiAxes: true, + pollingRateHz: last?.pollingRateHz ?? 0, + supportedPollingRates: this.getSupportedPollingRates(), + activeProfile: null, + debounceMs: last?.debounceMs ?? null, + sleepTimeout: null, + angleSnapping: last?.angleSnapping ?? null, + liftOffDistance: last?.lod ?? null, + connectionType: connection, + connectionDetail: connection === "Wireless" ? "2.4 GHz (Stellar Wireless)" : "Wired USB", + motionSync: null, + rippleControl: null, + dpiStages: dpiTable?.dpi.stages ? [...dpiTable.dpi.stages] : undefined, + dpiStagesY: dpiTable?.dpi.stagesY ? [...dpiTable.dpi.stagesY] : undefined, + activeDpiStage: 0, + firmware: [], + }; + } + + /** + * The DPI stage editor hook: the block carries five stages per axis, in the + * vendor's 50-step slots. + */ + async setDpiStages(stages: readonly number[], stagesY: readonly number[]): Promise { + if (stages.length !== ROCCAT_DPI_STAGE_COUNT || stagesY.length !== ROCCAT_DPI_STAGE_COUNT) { + throw new RangeError("The settings write carries five stages per axis."); + } + const settings: RoccatSettings = { + ...(this.lastWrite ?? DEFAULT_SETTINGS), + dpi: { stages: [...stages], stagesY: [...stagesY] }, + }; + await this.commit(settings); + } + + async setPollingRateHz(rateHz: number): Promise { + if (!ROCCAT_HZ_TO_POLLING_CODE.has(rateHz)) throw new RangeError(`Unsupported polling rate: ${rateHz} Hz.`); + const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), pollingRateHz: rateHz }; + await this.commit(settings); + } + + async setAngleSnapping(enabled: boolean): Promise { + const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), angleSnapping: enabled }; + await this.commit(settings); + } + + async setLiftOffDistance(lod: "Low" | "High"): Promise { + if (!ROCCAT_LOD_TO_CODE.has(lod)) throw new RangeError(`Unsupported lift-off distance: ${lod}`); + const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), lod }; + await this.commit(settings); + } + + async setDebounceMs(ms: number): Promise { + const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), debounceMs: ms }; + await this.commit(settings); + } + + async setLedTimeout(minutes: number): Promise { + const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), ledTimeoutMinutes: minutes }; + await this.commit(settings); + } + + /** + * Writes the replays-and-commits sequence: the 0x…50 0x14 settings write + * followed by the `…50 18 04` commit tail. + */ + private async commit(settings: RoccatSettings): Promise { + this.lastWrite = settings; + await this.run(async () => { + await this.write(roccatEncodePerformanceWrite(settings)); + await this.delay(50); + await this.write(roccatEncodeApply()); + }); + } + + /** + * Lighting is write-only — there is no known read-back. The two inner zones + * take raw RGB; callers pass `#rrggbb` colours. + */ + async setLighting(left: string, right: string): Promise { + const l = parseRgb(left); + const r = parseRgb(right); + await this.run(async () => { + await this.write(roccatEncodeLighting(l, r)); + }); + } +} + +const DEFAULT_SETTINGS: RoccatSettings = { + pollingRateHz: 1000, + angleSnapping: false, + lod: "Low", + ledTimeoutMinutes: 15, + debounceMs: 10, + dpi: { stages: [400, 800, 1200, 1600, 3200], stagesY: [400, 800, 1200, 1600, 3200] }, +}; + +function parseRgb(hex: string): [number, number, number] { + const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); + if (!match) throw new RangeError(`Colour must be #rrggbb, received ${hex}`); + const value = Number.parseInt(match[1]!, 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 3387e201..af508a68 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -10,6 +10,13 @@ import { ATK_COMPX_PRODUCT_IDS } from "./atk/products.ts"; import { MICROSOFT_PRODUCT_CLASSIC, MICROSOFT_PRODUCT_PRO, MICROSOFT_VENDOR_ID, MICROSOFT_CLASSIC_USAGE_PAGE, MICROSOFT_CLASSIC_USAGE, MICROSOFT_PRO_USAGE_PAGE, MICROSOFT_PRO_USAGE } from "../microsoft/index.ts"; import { FINALMOUSE_SLX_DONGLE_PRODUCT_IDS } from "../finalmouse/index.ts"; import { INCOTT_PRODUCT_IDS, INCOTT_USAGE_PAGE, INCOTT_VENDOR_ID } from "../incott/index.ts"; +import { + ROCCAT_CONFIG_USAGE, + ROCCAT_CONFIG_USAGE_PAGE, + ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + ROCCAT_KONE_PRO_AIR_WIRED_PID, + ROCCAT_VENDOR_ID, +} from "../roccat/index.ts"; import { GLORIOUS_CORE2_PRODUCTS, GLORIOUS_CORE2_USAGE_PAGE, GLORIOUS_CORE2_VENDOR_ID } from "../glorious-core2/index.ts"; import { EGG_WE_HID_FILTERS } from "./endgame/egg-we-control.ts"; import { VAXEE_PRODUCT_IDS, VAXEE_USAGE, VAXEE_USAGE_PAGE, VAXEE_VENDOR_ID } from "@openmouse/protocol/vaxee"; @@ -209,6 +216,7 @@ export const VENDOR_ID = { incott: INCOTT_VENDOR_ID, hyperxKingston: HYPERX_VENDOR_ID_KINGSTON, hyperxHp: HYPERX_VENDOR_ID_HP, + roccat: ROCCAT_VENDOR_ID, } as const; /** @@ -842,6 +850,14 @@ export const HYPERX_HID_FILTERS: HIDDeviceFilter[] = [ ...HYPERX_KINGSTON_HID_FILTERS, ...HYPERX_HP_HID_FILTERS, ]; + +// The Kone Pro family exposes one 0xFF00:0x0001 vendor interface across the +// whole composite (interface 2 of the dongle, interface 1 wired). There is no +// feature report on it, so the filter cannot pin a report id. +export const ROCCAT_HID_FILTERS: HIDDeviceFilter[] = [ + { vendorId: ROCCAT_VENDOR_ID, productId: ROCCAT_KONE_PRO_AIR_WIRELESS_PID, usagePage: ROCCAT_CONFIG_USAGE_PAGE, usage: ROCCAT_CONFIG_USAGE }, + { vendorId: ROCCAT_VENDOR_ID, productId: ROCCAT_KONE_PRO_AIR_WIRED_PID, usagePage: ROCCAT_CONFIG_USAGE_PAGE, usage: ROCCAT_CONFIG_USAGE }, +]; export const RYUNIX_HID_FILTERS: HIDDeviceFilter[] = [...RYUNIX_PRODUCT_IDS].map((productId) => ({ vendorId: RYUNIX_VENDOR_ID, productId, @@ -992,6 +1008,7 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...MICROSOFT_HID_FILTERS, ...INCOTT_HID_FILTERS, ...HYPERX_HID_FILTERS, + ...ROCCAT_HID_FILTERS, ...RYUNIX_HID_FILTERS, ...RAPOO_HID_FILTERS, { vendorId: VENDOR_ID.coolermaster, usagePage: COOLERMASTER_USAGE_PAGE, usage: COOLERMASTER_USAGE }, diff --git a/src/index.ts b/src/index.ts index 869b64cb..69ed15a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,3 +39,4 @@ export * as rapoo from "./rapoo/index.js"; export * as fater from "./fater/index.js"; export * as coolermaster from "./coolermaster/index.js"; export * as ajazz from "./ajazz/index.js"; +export * as roccat from "./roccat/index.js"; diff --git a/src/roccat/index.test.ts b/src/roccat/index.test.ts new file mode 100644 index 00000000..f26de3fe --- /dev/null +++ b/src/roccat/index.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ROCCAT_APPLY_FRAME, + ROCCAT_DPI_MAX, + ROCCAT_DPI_MIN, + ROCCAT_DPI_STEP, + ROCCAT_FRAME_PREFIX, + ROCCAT_PACKET_LENGTH, + ROCCAT_PERFORMANCE_CLASS, + ROCCAT_STATUS_COMMAND_ID, + roccatDecodeBattery, + roccatDecodeSettingsReply, + roccatEncodeApply, + roccatEncodeLighting, + roccatEncodePerformanceWrite, + roccatEncodeSettingsQuery, + roccatEncodeStatus, + roccatIsStatusReply, + roccatStagedDpi, + type RoccatSettings, +} from "./index.ts"; + +const hex = (bytes: Uint8Array, count: number) => [...bytes.subarray(0, count)].map((b) => b.toString(16).padStart(2, "0")).join(" "); +const pad = (list: number[]): RoccatSettings => ({ + pollingRateHz: 1000, + angleSnapping: false, + lod: "Low", + ledTimeoutMinutes: 15, + debounceMs: 10, + dpi: { stages: list, stagesY: list }, +}); + +test("the settings write matches the vendor-replayed frame byte for byte", () => { + const view = roccatEncodePerformanceWrite(pad([400, 800, 1200, 1600, 3200])); + assert.equal(view.length, ROCCAT_PACKET_LENGTH); + assert.equal( + hex(view, 24), + "10 50 14 00 01 00 00 00 08 08 10 10 18 18 20 20 40 40 06 00 80 0f 00 0a", + ); + assert.equal([...view.subarray(24)].every((b) => b === 0), true); +}); + +test("the apply commit tail and query are the recorded frames", () => { + assert.equal(hex(roccatEncodeApply(), 4), "10 50 18 04"); + assert.equal(hex(roccatEncodeSettingsQuery(), 3), "10 50 14"); + assert.equal(hex(roccatEncodeStatus(), 2), "90 0a"); +}); + +test("DPI is stored per 50 and validates scale and step", () => { + assert.equal(roccatStagedDpi(800), 16); + assert.equal(roccatStagedDpi(1000), 20); + assert.equal(ROCCAT_DPI_MAX / ROCCAT_DPI_STEP, 380); + assert.throws(() => roccatStagedDpi(801), RangeError); + assert.throws(() => roccatStagedDpi(ROCCAT_DPI_MIN - 50), RangeError); +}); + +test("settings writes validate every driven field", () => { + const settings = pad([800, 800, 800, 800, 800]); + const view = roccatEncodePerformanceWrite({ + ...settings, + pollingRateHz: 125, + angleSnapping: true, + lod: "High", + ledTimeoutMinutes: 0, + debounceMs: 0, + }); + assert.equal(view[0], ROCCAT_FRAME_PREFIX); + assert.equal(view[1], ROCCAT_PERFORMANCE_CLASS); + assert.equal(view[5], 0x03, "125 Hz -> 0x03, opposite of the Burst Core dict"); + assert.equal(view[6], 1, "angle snapping flag"); + assert.equal(view[18], 0x07, "LOD high"); + assert.equal(view[21], 0x00, "LED timeout minutes"); + assert.equal(view[23], 0x00, "debounce ms"); + assert.throws(() => roccatEncodePerformanceWrite({ ...settings, pollingRateHz: 2000 }), /polling rate/i); + assert.throws(() => roccatEncodePerformanceWrite({ ...settings, debounceMs: 11 }), /Debounce/); + assert.throws(() => roccatEncodePerformanceWrite({ ...settings, ledTimeoutMinutes: 31 }), /LED timeout/); +}); + +test("stage lists land in the two neighbouring byte slots", () => { + const settings: RoccatSettings = { + ...pad([800, 800, 800, 800, 800]), + dpi: { stages: [800, 1200, 1600, 3200, 5000], stagesY: [400, 800, 1200, 1600, 3200] }, + }; + const view = roccatEncodePerformanceWrite(settings); + assert.deepEqual([...view.subarray(8, 18)], [16, 8, 24, 16, 32, 24, 64, 32, 100, 64]); + const decoded = roccatDecodeSettingsReply(view)!; + assert.deepEqual(decoded.dpi.stages, [800, 1200, 1600, 3200, 5000]); + assert.deepEqual(decoded.dpi.stagesY, [400, 800, 1200, 1600, 3200]); +}); + +test("the settings echo decoder accepts the recorded factory frame", () => { + // The mouse's interrupt-IN reply to a settings query on this unit (this + // unit's stage table had five entries, both axes identical). Reported as + // `10 50 03 00 ec fa 0d 00 10 10 18 18 20 20 40 40 64 64 01 02 00 00`. + const echo = new Uint8Array([ + 0x10, 0x50, 0x03, 0x00, 0xec, 0xfa, 0x0d, 0x00, + 0x10, 0x10, 0x18, 0x18, 0x20, 0x20, 0x40, 0x40, 0x64, 0x64, + 0x01, 0x02, 0x00, 0x00, + ]); + const decoded = roccatDecodeSettingsReply(echo); + assert.deepEqual(decoded?.dpi.stages, [800, 1200, 1600, 3200, 5000]); + assert.deepEqual(decoded?.dpi.stagesY, [800, 1200, 1600, 3200, 5000]); +}); + +test("the settings echo decoder refuses foreign frames", () => { + assert.equal(roccatDecodeSettingsReply(new Uint8Array([0x90, 0x0a, 0x04])), null); + assert.equal(roccatDecodeSettingsReply(new Uint8Array([0x10, 0x10, 0x0b])), null); + assert.equal(roccatDecodeSettingsReply(new Uint8Array([0x10, 0x50, 0x51])), null); + assert.equal(roccatDecodeSettingsReply(new Uint8Array(10)), null); +}); + +test("an all-zero stage table decodes as no-data, the sleeper echo", () => { + // What an idle/sleeping mouse returns per poll in the second capture: the + // echo of `10 50 03 00 ec fa 04` with an empty body. + const sleeper = new Uint8Array([ + 0x10, 0x50, 0x03, 0x00, 0xec, 0xfa, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]); + assert.equal(roccatDecodeSettingsReply(sleeper), null); + // One non-zero slot is a real table again. + const one = new Uint8Array([...sleeper]); + one[8] = 0x10; + assert.deepEqual(roccatDecodeSettingsReply(one)?.dpi.stages, [800, 0, 0, 0, 0]); +}); + +test("battery decodes from the status echo recorded on hardware", () => { + assert.equal(roccatIsStatusReply(new Uint8Array([0x90, 0x0a])), true); + assert.equal(roccatIsStatusReply(new Uint8Array([0x10, 0x50, 0x03])), false); + // The echo every poll of this unit returned; byte 5 held 0x1e. + assert.equal(roccatDecodeBattery(new Uint8Array([0x90, 0x0a, 0x04, 0x00, 0x00, 0x1e])), 0x1e); + assert.equal(roccatDecodeBattery(new Uint8Array([0x90, 0x0a, 0x04, 0x00, 0x00])), null); + assert.equal(roccatDecodeBattery(new Uint8Array([0x90, 0x0a, 0x04, 0x00, 0x00, 101])), null); + assert.equal(roccatDecodeBattery(new Uint8Array([0x10, 0x50, 0x03])), null); + assert.equal(ROCCAT_STATUS_COMMAND_ID, 0x90); +}); + +test("lighting frames carry the two zones after the vendor prefix", () => { + const view = roccatEncodeLighting([0x12, 0x34, 0x56], [0, 0, 0]); + assert.equal(hex(view, 15), "10 10 0b 00 09 64 64 64 06 12 34 56 00 00 00"); + assert.equal([...view.subarray(15)].every((b) => b === 0), true); +}); diff --git a/src/roccat/index.ts b/src/roccat/index.ts new file mode 100644 index 00000000..a5f1c6b4 --- /dev/null +++ b/src/roccat/index.ts @@ -0,0 +1,339 @@ +/** + * ROCCAT Kone Pro Air (`1e7d:2c8e` via the Stellar Wireless dongle, `1e7d:2c92` + * wired) and sibling Kone Pro firmware. + * + * Transport: the mouse speaks on the composite's vendor interface (usage page + * 0xFF00, usage 0x0001 鈥?interface 2 of the dongle, interface 1 wired) with + * plain unnumbered output reports. WebHID callers send + * `sendReport(0, payload64)`; the mouse answers on the same interface's + * interrupt-IN stream. There is no feature report here 鈥?a full 0x00鈥?xFF + * GET sweep on this unit answered nothing (2026-10-14, Windows node-hid, + * `captures/roccat-kone-pro-air/`). + * + * Payload frame layout (device coordinates, ahead of no report id): + * + * - `0x10 0x50 ` 鈥?performance class: the settings write / query (0x14), + * the commit tail `0x10 0x50 0x18 0x04`, and the commit (0x19); + * - `0x10 0x10 ` 鈥?the lighting write (`0x0b` + two RGB zones); + * - `0x90 0x0a` 鈥?the unnamed status probe, answered `0x90 0x0a 04 00 00 `. + * + * This is the protocol the vendor software replays; the DPI table write, the + * commit tail, the status probe and the lighting write were exercised on this + * unit (see `captures/roccat-kone-pro-air/`). What is proven on hardware: the + * frames are accepted, the background status stream answers `90 0a 04 鈥, and + * a settings echo carried a full stage table on one session with the mouse + * awake. What is not: a byte-precise before/after proof that the stage table + * write applies another read-back than the sleeper echoes an empty body for. + * The fields carry their known/unknown state through 鈥?unknown bytes stay + * unknown rather than guessed. + */ + +export const ROCCAT_VENDOR_ID = 0x1e7d; // ROCCAT GmbH (now Turtle Beach) + +export const ROCCAT_KONE_PRO_AIR_WIRELESS_PID = 0x2c8e; // Stellar Wireless dongle +export const ROCCAT_KONE_PRO_AIR_WIRED_PID = 0x2c92; // wired generation +export const ROCCAT_KONE_PRO_PID = 0x2c8f; // wired Kone Pro, same Swarm family + +export const ROCCAT_PRODUCT_NAMES: ReadonlyMap = new Map([ + [ROCCAT_KONE_PRO_AIR_WIRELESS_PID, "ROCCAT Kone Pro Air"], + [ROCCAT_KONE_PRO_AIR_WIRED_PID, "ROCCAT Kone Pro Air (Wired)"], +]); + +export const ROCCAT_PRODUCT_IDS: readonly number[] = [ + ROCCAT_KONE_PRO_AIR_WIRELESS_PID, + ROCCAT_KONE_PRO_AIR_WIRED_PID, +]; + +/** Vendor collection on the dongle's interface 2 (interface 1 when wired). */ +export const ROCCAT_CONFIG_USAGE_PAGE = 0xff00; +export const ROCCAT_CONFIG_USAGE = 0x0001; + +/** Payload size of every config frame; unnumbered report id 0. */ +export const ROCCAT_PACKET_LENGTH = 64; +export const ROCCAT_REPORT_ID = 0x00; + +/** Byte 0 of a 64-byte config frame: carries no discovered meaning. */ +export const ROCCAT_FRAME_PREFIX = 0x10; + +export const ROCCAT_PERFORMANCE_CLASS = 0x50; +export const ROCCAT_LIGHTING_CLASS = 0x10; +export const ROCCAT_STATUS_COMMAND_ID = 0x90; + +export const ROCCAT_PERFORMANCE_COMMAND = { + settingsGroup: 0x14, + applyGroup: 0x18, + commitGroup: 0x19, +} as const; + +/** Unnamed status probe SignalRGB ends its setup with: `90 0a`. */ +export const ROCCAT_STATUS_COMMAND: readonly number[] = [ + ROCCAT_STATUS_COMMAND_ID, + 0x0a, +]; + +export const ROCCAT_POLLING_TABLE = [125, 250, 500, 1000] as const; +/** Opposite of the Burst Core polling dict (125 -> 0x03 here). */ +export const ROCCAT_POLLING_CODE_TO_HZ: ReadonlyMap = new Map([ + [0x00, 1000], + [0x01, 500], + [0x02, 250], + [0x03, 125], +]); +export const ROCCAT_HZ_TO_POLLING_CODE: ReadonlyMap = new Map([ + [1000, 0x00], + [500, 0x01], + [250, 0x02], + [125, 0x03], +]); + +/** DPI is stored per 50. Vendor scale: 50 鈥?19,000. */ +export const ROCCAT_DPI_MIN = 50; +export const ROCCAT_DPI_MAX = 19000; +export const ROCCAT_DPI_STEP = 50; +/** The settings write carries five stages of each axis. */ +export const ROCCAT_DPI_STAGE_COUNT = 5; + +/** LOD byte in the settings write: 0x06 = low, 0x07 = high (write path only). */ +export const ROCCAT_LOD = { + low: 0x06, + high: 0x07, +} as const; +export type RoccatLod = "Low" | "High"; + +export const ROCCAT_LOD_TO_CODE: ReadonlyMap = new Map([ + ["Low", ROCCAT_LOD.low], + ["High", ROCCAT_LOD.high], +]); + +export const ROCCAT_DEBOUNCE_MIN_MS = 0; +export const ROCCAT_DEBOUNCE_MAX_MS = 10; + +export const ROCCAT_LED_TIMEOUT_MIN_MINUTES = 0; +export const ROCCAT_LED_TIMEOUT_MAX_MINUTES = 30; + +export interface RoccatDpiStages { + /** The five 50-step slots the vendor software's stage list writes. */ + stages: readonly number[]; + /** The counterpart five slots carried in the neighbouring byte list. */ + stagesY: readonly number[]; +} + +export interface RoccatSettings { + pollingRateHz: number; + angleSnapping: boolean; + lod: RoccatLod; + ledTimeoutMinutes: number; + debounceMs: number; + dpi: RoccatDpiStages; +} + +/** + * DPI value to its 50-step slot byte. The wire carries dpi/50; 19,000/50 is + * 380, which fits, so the whole documented scale encodes losslessly. + */ +export function roccatStagedDpi(dpi: number): number { + if (!Number.isInteger(dpi / ROCCAT_DPI_STEP)) { + throw new RangeError(`DPI must step by ${ROCCAT_DPI_STEP}, received ${dpi}.`); + } + const value = dpi / ROCCAT_DPI_STEP; + if (!Number.isInteger(value) || value < 1 || value > 0xff) { + throw new RangeError(`DPI ${dpi} does not fit the 50-step byte slot.`); + } + return value; +} + +/** + * Byte-for-byte the frame the vendor software replays, in device coordinates: + * + * ```text + * 10 50 14 00 01 00 + * + * 00 80 00 + * ``` + * + * The DPI slots land at payload bytes 8..17 鈥?even bytes for the stage list + * SignalRGB drives from its DPI entries, odd bytes for the other axis. On this + * unit the axis-byte assignment reads back symmetrically (both lists held the + * same five values from the factory), so the codec writes both axes and marks + * which is which as unestablished. + */ +export function roccatEncodePerformanceWrite(settings: RoccatSettings): Uint8Array { + const pollingCode = ROCCAT_HZ_TO_POLLING_CODE.get(settings.pollingRateHz); + if (pollingCode === undefined) { + throw new RangeError(`Unsupported polling rate: ${settings.pollingRateHz} Hz. Pick one of ${[...ROCCAT_POLLING_TABLE].join(", ")}.`); + } + if ( + !ROCCAT_LOD_TO_CODE.has(settings.lod) + ) { + throw new RangeError(`Unsupported lift-off distance: ${settings.lod ?? "(none)"}.`); + } + if ( + !Number.isInteger(settings.ledTimeoutMinutes) + || settings.ledTimeoutMinutes < ROCCAT_LED_TIMEOUT_MIN_MINUTES + || settings.ledTimeoutMinutes > ROCCAT_LED_TIMEOUT_MAX_MINUTES + ) { + throw new RangeError(`LED timeout must be a whole number of minutes between ${ROCCAT_LED_TIMEOUT_MIN_MINUTES} and ${ROCCAT_LED_TIMEOUT_MAX_MINUTES}.`); + } + if ( + !Number.isInteger(settings.debounceMs) + || settings.debounceMs < ROCCAT_DEBOUNCE_MIN_MS + || settings.debounceMs > ROCCAT_DEBOUNCE_MAX_MS + ) { + throw new RangeError(`Debounce must be between ${ROCCAT_DEBOUNCE_MIN_MS} and ${ROCCAT_DEBOUNCE_MAX_MS} ms.`); + } + if ( + settings.dpi.stages.length !== ROCCAT_DPI_STAGE_COUNT + || settings.dpi.stagesY.length !== ROCCAT_DPI_STAGE_COUNT + ) { + throw new RangeError(`The settings write carries ${ROCCAT_DPI_STAGE_COUNT} stages per axis.`); + } + for (const stage of [...settings.dpi.stages, ...settings.dpi.stagesY]) { + roccatStagedDpi(stage); + } + const view = new Uint8Array(ROCCAT_PACKET_LENGTH); + view[0] = ROCCAT_FRAME_PREFIX; + view[1] = ROCCAT_PERFORMANCE_CLASS; + view[2] = ROCCAT_PERFORMANCE_COMMAND.settingsGroup; + view[3] = 0x00; + view[4] = 0x01; + view[5] = pollingCode; + view[6] = settings.angleSnapping ? 1 : 0; + view[7] = 0x00; + for (let index = 0; index < ROCCAT_DPI_STAGE_COUNT; index += 1) { + view[8 + index * 2] = roccatStagedDpi(settings.dpi.stages[index]!); + view[9 + index * 2] = roccatStagedDpi(settings.dpi.stagesY[index]!); + } + view[18] = ROCCAT_LOD_TO_CODE.get(settings.lod)!; + view[19] = 0x00; + view[20] = 0x80; + view[21] = settings.ledTimeoutMinutes; + view[22] = 0x00; + view[23] = settings.debounceMs; + return view; +} + +/** The commit tail the vendor software sends after a settings write. */ +export const ROCCAT_APPLY_FRAME: readonly number[] = [ROCCAT_FRAME_PREFIX, ROCCAT_PERFORMANCE_CLASS, ROCCAT_PERFORMANCE_COMMAND.applyGroup, 0x04]; + +export function roccatEncodeApply(): Uint8Array { + const view = new Uint8Array(ROCCAT_PACKET_LENGTH); + [...ROCCAT_APPLY_FRAME].forEach((byte, index) => { view[index] = byte; }); + return view; +} + +/** The status probe, answered `90 0a 鈥 on the input stream. */ +export function roccatEncodeStatus(): Uint8Array { + const view = new Uint8Array(ROCCAT_PACKET_LENGTH); + view[0] = ROCCAT_STATUS_COMMAND[0]!; + view[1] = ROCCAT_STATUS_COMMAND[1]!; + return view; +} + +/** The empty settings query 鈥?a 0x14 frame with no arguments. */ +export function roccatEncodeSettingsQuery(): Uint8Array { + const view = new Uint8Array(ROCCAT_PACKET_LENGTH); + view[0] = ROCCAT_FRAME_PREFIX; + view[1] = ROCCAT_PERFORMANCE_CLASS; + view[2] = ROCCAT_PERFORMANCE_COMMAND.settingsGroup; + return view; +} + +/** Reply to the status probe recorded on this unit: `90 0a 04 00 00 = 2 && reply[0] === ROCCAT_STATUS_COMMAND[0] && reply[1] === ROCCAT_STATUS_COMMAND[1]; +} + +export interface RoccatStatus { + batteryPercent: number | null; +} + +/** + * The one number the status echo plausibly reports for the UI. On this unit + * every poll answered `90 0a 04 00 00 1e 00 00 1e` 鈥?bytes 5 and 10 + * both held 0x1e while bytes 8鈥? carried a slowly decreasing u16 (2720 鈫? * 2714 鈫?2711, shaped like an estimated-use-remaining counter). Neither byte + * is confirmed as the battery percent: signal strength and elapsed session + * time fit the same bytes. Byte 5 is decoded here the same way SignalRGB + * reads this frame ("batt/signal guessing" is its own comment); its byte-moves + * (0x1e -> 0x14) stay unexplained, so the mapping stays flagged until someone + * watches it drain. Values above 100 are refused rather than wrapped. + */ +export function roccatDecodeBattery(reply: Uint8Array): number | null { + if (!roccatIsStatusReply(reply)) return null; + const percent = reply[5]; + if (percent === undefined || percent > 100) return null; + return percent; +} + +/** + * Reply to a settings query / the echo after a write, on this unit: + * `10 50 03 00 00 鈥. The class + * byte 2 was 0x03 on every observed variant; frames that echo the driven + * command (`0x14` writes) carry it at byte 2 as well. Only the frame prefix + * and the DPI table are decoded 鈥?the remaining bytes (the device word, the + * sequence marker, LOD/angle positions in the echo) are not established. + */ +export interface RoccatSettingsReply { + dpi: RoccatDpiStages; +} + +export function roccatDecodeSettingsReply(reply: Uint8Array): RoccatSettingsReply | null { + if ( + reply.length < 18 + || reply[0] !== ROCCAT_FRAME_PREFIX + || reply[1] !== ROCCAT_PERFORMANCE_CLASS + || reply[2] === undefined + ) { + return null; + } + if (reply[2] !== 0x03 && reply[2] !== ROCCAT_PERFORMANCE_COMMAND.settingsGroup + && reply[2] !== ROCCAT_PERFORMANCE_COMMAND.applyGroup) { + return null; + } + const stageSlots: number[] = []; + const stageSlotsY: number[] = []; + for (let index = 0; index < ROCCAT_DPI_STAGE_COUNT; index += 1) { + stageSlots.push(reply[8 + index * 2]!); + stageSlotsY.push(reply[9 + index * 2]!); + } + // An asleep mouse echoes the frame with an empty body (all twenty slots + // zero). Reporting that as five stages of 0 DPI would invent a state, so an + // all-zero table means "no data" exactly like a missed echo does. + if (stageSlots.every((slot) => slot === 0) && stageSlotsY.every((slot) => slot === 0)) return null; + return { + dpi: { + stages: stageSlots.map((slot) => slot * ROCCAT_DPI_STEP), + stagesY: stageSlotsY.map((slot) => slot * ROCCAT_DPI_STEP), + }, + }; +} + +/** + * The lighting write: `10 10 0b 00 09 64 64 64 06 ` with the + * two 0x64 "brightness" words the vendor software sends. Write-only: the + * mouse has no known command that reads its current effect back. The frame is + * accepted with identical replies across repeats, and deliberately left the + * mouse's LEDs stable on this session. + */ +export const ROCCAT_LIGHTING_PREFIX: readonly number[] = [ + ROCCAT_FRAME_PREFIX, + ROCCAT_LIGHTING_CLASS, + 0x0b, + 0x00, + 0x09, + 0x64, + 0x64, + 0x64, + 0x06, +]; + +export function roccatEncodeLighting(left: readonly [number, number, number], right: readonly [number, number, number]): Uint8Array { + const view = new Uint8Array(ROCCAT_PACKET_LENGTH); + [...ROCCAT_LIGHTING_PREFIX].forEach((byte, index) => { view[index] = byte; }); + view[9] = left[0]; + view[10] = left[1]; + view[11] = left[2]; + view[12] = right[0]; + view[13] = right[1]; + view[14] = right[2]; + return view; +} From 1ca94e06d0b7428f36ebf557f58e0ced3d8dab27 Mon Sep 17 00:00:00 2001 From: Kissshot-2003 <311225363+Kissshot-2003@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:17:48 +0800 Subject: [PATCH 2/6] fix(roccat): read-patch-write-confirm the settings block and drop the DPI/battery overreach Addresses the CodeRabbit review on the ROCCAT Kone Pro Air driver: - Reads now run on the same queue as writes. The settings query and the settings write share class byte 1 (0x50), so an unserialized read could accept a concurrent write's echo as its reply; readDpiTable/readStatus are serialized through run(). - Setters no longer substitute DEFAULT_SETTINGS for the stored DPI stages. A settings frame replaces the whole block, so each setter now follows the repo's read-patch-write-confirm cycle (as in the AJAZZ driver): read the current table, patch one field, write and commit, then read the table back and confirm. A polling-only change can no longer re-scale the mouse, and a write is refused when the mouse will not report its table. - lastWrite is only stored after the frame encodes and the write is confirmed, so a rejected value (e.g. 19,050 DPI) no longer poisons the next setter. - ROCCAT_DPI_MAX drops from 19,000 to 12,750 (255 x 50): 19,000/50 = 380 does not fit the single slot byte, so the advertised max was unencodable. Tested through roccatEncodePerformanceWrite, not just by division. - The status byte is no longer decoded as a battery percent. Its meaning is unresolved (0x1e -> 0x14 across sessions), so the driver surfaces no battery reading instead of a guess; roccatIsStatusReply still identifies the reply. npm run check: 2502 tests, 0 failures. --- docs/roccat-kone-pro-air-testing.md | 19 +++- src/drivers/roccat/hid.test.ts | 106 ++++++++++++++-------- src/drivers/roccat/hid.ts | 133 +++++++++++++++++----------- src/roccat/index.test.ts | 18 ++-- src/roccat/index.ts | 44 ++++----- 5 files changed, 200 insertions(+), 120 deletions(-) diff --git a/docs/roccat-kone-pro-air-testing.md b/docs/roccat-kone-pro-air-testing.md index 23732815..d4fcfa9c 100644 --- a/docs/roccat-kone-pro-air-testing.md +++ b/docs/roccat-kone-pro-air-testing.md @@ -57,8 +57,10 @@ axes interleaved. - **The status probe answers every poll**: `90 0a 04 00 00 00 `, where byte 5 moved `0x1e → 0x14` between sessions and bytes 8–9 *increased* (2720 → 2884) — falsifying the "estimated minutes - remaining" reading of that word. Byte 5 is the number the decode surfaces, - with an explicit unconfirmed-mapping caveat in the code. + remaining" reading of that word. Byte 5's meaning stays unresolved + (a battery percent, a signal strength and an elapsed counter all fit), so + the driver surfaces **no battery reading at all** rather than a guess; + `roccatIsStatusReply` still identifies the reply. - **The lighting write is idempotent** on repeat (three identical writes returned identical answers), and leaves the mouse's LEDs stable. There is no known read-back, so the codec ships lighting write-only. @@ -70,6 +72,19 @@ established echo positions, so those UI values surface from the driver's own last write and the shared cards stay consistent with other write-only-cached drivers (this mirrors the Razer driver's approach). +## Write path + +The settings frame replaces the whole block, so each setter runs the repo's +read-patch-write-confirm cycle (as in the AJAZZ driver): read the mouse's +current table, patch the one field, write and commit, then read the table back +and confirm the mouse kept it. The pre-read preserves the stored DPI stages — +a polling-only change must not silently re-scale the mouse — and a write is +refused if the mouse will not report its table rather than substituting +defaults. A read-back that disagrees is reported as a failure, not success. +DPI is capped at 12,750 (255 × 50), the largest value the single slot byte can +carry; the vendor software's higher numbers were never captured and do not fit +this byte. + ## Open questions (any owner can settle these with a replug) - Which slot list is sensor X and which is Y (both printed identical here). diff --git a/src/drivers/roccat/hid.test.ts b/src/drivers/roccat/hid.test.ts index f71cd32a..06459735 100644 --- a/src/drivers/roccat/hid.test.ts +++ b/src/drivers/roccat/hid.test.ts @@ -11,23 +11,36 @@ import { const hex = (bytes: Uint8Array, count: number) => [...bytes.subarray(0, count)].map((b) => b.toString(16).padStart(2, "0")).join(" "); +/** The stage table the unit held in the awake capture, in 50-step slots. */ +const STORED_TABLE = [16, 16, 24, 24, 32, 32, 64, 64, 100, 100]; + interface FakeOptions { - /** Replies the mouse returns for each frame class it sees (by byte 1). */ - replies?: Record; productId?: number; usagePage?: number; usage?: number; + /** Initial stored stage table (X and Y interleaved); defaults to the capture. */ + table?: readonly number[]; + /** When true the mouse ignores writes, so a read-back can never match. */ + locksTable?: boolean; + /** When true the mouse echoes an empty (all-zero) table, i.e. an idle unit. */ + silent?: boolean; } function fakeDevice(options: FakeOptions = {}) { const { - replies = {}, productId = ROCCAT_KONE_PRO_AIR_WIRELESS_PID, usagePage = ROCCAT_CONFIG_USAGE_PAGE, usage = ROCCAT_CONFIG_USAGE, + locksTable = false, + silent = false, } = options; + let stored = Uint8Array.from(options.table ?? STORED_TABLE); const sent: Uint8Array[] = []; let listener: ((event: HIDInputReportEvent) => void) | null = null; + const emit = (frame: Uint8Array) => { + if (!listener) return; + listener({ data: new DataView(frame.buffer, frame.byteOffset, frame.byteLength) } as unknown as HIDInputReportEvent); + }; const device = { vendorId: ROCCAT_VENDOR_ID, productId, @@ -40,19 +53,23 @@ function fakeDevice(options: FakeOptions = {}) { removeEventListener: () => { listener = null; }, sendReport: async (_id: number, data: Uint8Array) => { sent.push(new Uint8Array(data)); - const reply = replies[data[0]!]; - if (reply && listener) { - const payload = new Uint8Array(reply); - listener({ data: new DataView(payload.buffer) } as unknown as HIDInputReportEvent); + // A settings write (0x10 0x50 0x14, byte 4 = 0x01) stores the table it + // carries, unless the fake is simulating a mouse that ignores writes. + if (data[0] === 0x10 && data[1] === 0x50 && data[2] === 0x14 && data[4] === 0x01 && !locksTable) { + stored = data.slice(8, 18); + } + // Any settings-class frame echoes the current stored table. + if (data[0] === 0x10 && data[1] === 0x50 && data[2] === 0x14) { + const frame = new Uint8Array(24); + frame.set([0x10, 0x50, 0x03, 0x00, 0xec, 0xfa, 0x0d, 0x00]); + if (!silent) frame.set(stored, 8); + emit(frame); } }, } as unknown as HIDDevice; - return { device, sent }; + return { device, sent, storedTable: () => [...stored] }; } -const SETTINGS_ECHO = [0x10, 0x50, 0x03, 0x00, 0xec, 0xfa, 0x0d, 0x00, 0x10, 0x10, 0x18, 0x18, 0x20, 0x20, 0x40, 0x40, 0x64, 0x64, 0x06]; -const BATTERY_ECHO = [0x90, 0x0a, 0x04, 0x00, 0x00, 0x1e]; - test("isSupported needs the ROCCAT VID, a known PID and the 0xFF00 vendor collection", () => { assert.equal(RoccatHidClient.isSupported(fakeDevice().device), true); assert.equal(RoccatHidClient.isSupported(fakeDevice({ productId: 0x2c92 }).device), true); @@ -61,13 +78,14 @@ test("isSupported needs the ROCCAT VID, a known PID and the 0xFF00 vendor collec assert.equal(RoccatHidClient.isSupported(fakeDevice({ productId: 0x2de6 }).device), false); }); -test("readStatus reads battery and the DPI table, neither invented", async () => { - const { device } = fakeDevice({ replies: { 0x10: SETTINGS_ECHO, 0x90: BATTERY_ECHO } }); +test("readStatus surfaces the stored DPI table and claims no battery", async () => { + const { device } = fakeDevice(); const status = await new RoccatHidClient(device).readStatus(); assert.equal(status.brand, "ROCCAT"); assert.equal(status.name, "ROCCAT Kone Pro Air"); - assert.equal(status.batteryPercent, 0x1e, 'byte 5 of the recorded echo; the percent mapping is unconfirmed'); - assert.equal(status.batteryState, "Discharging"); + assert.equal(status.batteryPercent, null, "the unconfirmed status byte is never surfaced as a percent"); + assert.equal(status.batteryState, "Unknown"); + assert.equal(status.ui?.forceShowBattery, false); assert.equal(status.connectionType, "Wireless"); assert.deepEqual(status.dpiStages, [800, 1200, 1600, 3200, 5000]); assert.deepEqual(status.dpiStagesY, [800, 1200, 1600, 3200, 5000]); @@ -78,7 +96,7 @@ test("readStatus reads battery and the DPI table, neither invented", async () => }); test("readStatus degrades to nothing when the mouse never answers", async () => { - const { device } = fakeDevice(); + const { device } = fakeDevice({ silent: true }); const status = await new RoccatHidClient(device).readStatus(); assert.equal(status.batteryPercent, null); assert.equal(status.dpi, 0); @@ -87,34 +105,55 @@ test("readStatus degrades to nothing when the mouse never answers", async () => assert.match(status.ui?.statusNote ?? "", /did not answer/); }); -test("setPollingRateHz sends the settings write then the commit tail", async () => { - const { device, sent } = fakeDevice(); +test("setPollingRateHz sends the write and commit tail without touching the DPI table", async () => { + const { device, sent, storedTable } = fakeDevice(); await new RoccatHidClient(device).setPollingRateHz(125); - assert.equal(sent.length, 2); - assert.equal(sent[0]![0], 0x10); - assert.equal(sent[0]![1], 0x50); - assert.equal(sent[0]![2], 0x14); - assert.equal(sent[0]![5], 0x03, "125 Hz writes code 0x03"); - assert.equal(hex(sent[1]!, 4), "10 50 18 04", "commit tail"); + const frames = sent.filter((frame) => frame[1] === 0x50 && frame[2] === 0x14 && frame[4] === 0x01); + assert.equal(frames.length, 1, "one settings write"); + assert.equal(frames[0]![2], 0x14); + assert.equal(frames[0]![5], 0x03, "125 Hz writes code 0x03"); + const apply = sent.find((frame) => hex(frame, 4) === "10 50 18 04"); + assert.ok(apply, "commit tail sent"); assert.equal(sent.every((frame) => frame.length === ROCCAT_PACKET_LENGTH), true); + assert.deepEqual(storedTable(), STORED_TABLE, "the stored stages are preserved, not replaced with defaults"); + assert.deepEqual([...frames[0]!.subarray(8, 18)], STORED_TABLE, "the write carries the preserved table"); }); -test("setDpiStages validates stage counts and encodes the five stages", async () => { +test("setDpiStages reads, writes the five stages, and confirms them", async () => { const { device, sent } = fakeDevice(); const client = new RoccatHidClient(device); await client.setDpiStages([800, 1200, 1600, 3200, 5000], [400, 800, 1200, 1600, 3200]); - assert.deepEqual([...sent[0]!.subarray(8, 18)], [16, 8, 24, 16, 32, 24, 64, 32, 100, 64]); + const write = sent.find((frame) => frame[1] === 0x50 && frame[2] === 0x14 && frame[4] === 0x01); + assert.ok(write); + assert.deepEqual([...write!.subarray(8, 18)], [16, 8, 24, 16, 32, 24, 64, 32, 100, 64]); await assert.rejects( () => client.setDpiStages([800, 1200, 1600, 3200], [400, 800, 1200, 1600, 3200]), /five stages/, ); }); -test("setDpiStages rejects out-of-scale and off-step DPI", async () => { - const { device } = fakeDevice(); +test("a rejected write does not poison the next write", async () => { + const { device, sent } = fakeDevice(); const client = new RoccatHidClient(device); await assert.rejects(() => client.setDpiStages([800, 1200, 1600, 3200, 19050], [800, 800, 800, 800, 800]), RangeError); - await assert.rejects(() => client.setDpiStages([800, 1200, 1600, 3200, 801], [800, 800, 800, 800, 800]), RangeError); + await client.setPollingRateHz(1000); + const write = sent.filter((frame) => frame[1] === 0x50 && frame[2] === 0x14 && frame[4] === 0x01).at(-1); + assert.equal(write![5], 0x00, "1000 Hz -> 0x00"); + assert.deepEqual([...write!.subarray(8, 18)], STORED_TABLE, "still carrying the real stored table"); +}); + +test("a mouse that ignores the write is not reported as success", async () => { + const { device } = fakeDevice({ locksTable: true }); + const client = new RoccatHidClient(device); + await assert.rejects( + () => client.setDpiStages([400, 400, 400, 400, 400], [400, 400, 400, 400, 400]), + /instead of the requested/, + ); +}); + +test("a setter refuses to guess when the mouse will not report its table", async () => { + const { device } = fakeDevice({ silent: true }); + await assert.rejects(() => new RoccatHidClient(device).setPollingRateHz(125), /refused/); }); test("setLighting writes one frame with both zones", async () => { @@ -125,13 +164,10 @@ test("setLighting writes one frame with both zones", async () => { await assert.rejects(() => new RoccatHidClient(device).setLighting("blue", "#000000"), /rrggbb/); }); -test("wired mode reports Charging and the 2.4 GHz dongle reports Discharging", async () => { - const { device } = fakeDevice({ - productId: 0x2c92, - replies: { 0x90: BATTERY_ECHO, 0x10: SETTINGS_ECHO }, - }); +test("wired mode reports the wired connection and still no battery", async () => { + const { device } = fakeDevice({ productId: 0x2c92 }); const status = await new RoccatHidClient(device).readStatus(); assert.equal(status.connectionType, "Wired"); - assert.equal(status.batteryState, "Charging"); + assert.equal(status.batteryState, "Unknown"); assert.equal(status.connectionDetail, "Wired USB"); }); diff --git a/src/drivers/roccat/hid.ts b/src/drivers/roccat/hid.ts index bf4846bf..362bc177 100644 --- a/src/drivers/roccat/hid.ts +++ b/src/drivers/roccat/hid.ts @@ -13,14 +13,14 @@ import { ROCCAT_PRODUCT_NAMES, ROCCAT_REPORT_ID, ROCCAT_VENDOR_ID, - roccatDecodeBattery, roccatDecodeSettingsReply, roccatEncodeApply, roccatEncodeLighting, roccatEncodePerformanceWrite, roccatEncodeSettingsQuery, - roccatEncodeStatus, + type RoccatDpiStages, type RoccatSettings, + type RoccatSettingsReply, } from "../../roccat/index.ts"; /** @@ -33,11 +33,19 @@ import { * every frame here was replayed against the dongle and accepted, the status * stream answers `90 0a 04 …`, and a settings echo carried a full stage table * once the mouse was awake (an asleep mouse echoes a zero body, which the - * decoder has to treat as no-data). What is deliberately not faked: the - * echo's polling/LOD/debounce positions and the battery-to-percent mapping - * are not established, so the UI surfaces those values only from the - * driver's own last write and labels the read as unknown — exactly how the - * Razer driver surfaces write-only fields. + * decoder has to treat as no-data). + * + * Writes follow the repo's read-patch-write-confirm cycle (as in the AJAZZ + * driver): the settings write replaces the whole block, so every setter reads + * the mouse's current table first, patches the one field, writes and commits, + * then reads the table back and refuses to report success unless the mouse + * kept it. The pre-read is also what preserves the stored DPI stages: a + * non-DPI setter (polling rate, LOD, …) would otherwise substitute defaults + * and silently re-scale the mouse. What stays deliberately unfolded: the + * echo's polling/LOD/debounce positions are not established, so the UI + * surfaces those values only from the driver's own last write, exactly how + * the Razer driver handles write-only fields, and no battery reading is + * surfaced at all because the status byte's mapping is unresolved. */ export class RoccatHidClient { readonly device: HIDDevice; @@ -103,6 +111,15 @@ export class RoccatHidClient { return next; } + /** + * A read runs through the same queue as writes: the settings query and the + * settings write share class byte 1 (0x50), so an unserialized read could + * accept a concurrent write's echo as its reply. + */ + private read(payload: Uint8Array): Promise { + return this.run(() => this.transact(payload)); + } + private delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } @@ -141,29 +158,20 @@ export class RoccatHidClient { return echo; } - /** Battery percent, or null when the status probe goes unanswered. */ - async readBattery(): Promise { - const echo = await this.transact(roccatEncodeStatus()); - return echo ? roccatDecodeBattery(echo) : null; - } - /** The DPI table read. Null when the mouse never answers the query. */ - async readDpiTable(): Promise> { - const echo = await this.transact(roccatEncodeSettingsQuery()); + async readDpiTable(): Promise { + const echo = await this.read(roccatEncodeSettingsQuery()); return echo ? roccatDecodeSettingsReply(echo) : null; } /** - * Status snapshot. Battery is a verified read; the DPI table is a verified - * read; polling rate, LOD, angle snapping and debounce are reported from the - * driver's last write (or neutral defaults before one), matching how the - * Razer driver surfaces write-only fields. + * Status snapshot. The DPI table is a verified read; polling rate, LOD, + * angle snapping and debounce are reported from the driver's last write (or + * neutral defaults before one), matching how the Razer driver surfaces + * write-only fields. No battery reading is surfaced: the status byte's + * meaning is unresolved (see the codec). */ async readStatus(): Promise { - // Serial, not Promise.all: concurrent transactions would race each - // other's input-report listeners on a link that answers one echo at a - // time. - const battery = await this.readBattery(); const dpiTable = await this.readDpiTable(); const name = this.displayName(); const connection = this.connectionType(); @@ -174,23 +182,21 @@ export class RoccatHidClient { ui: { family: "roccat", settingsReady: dpiTable !== null, - valuesVerified: dpiTable !== null || battery !== null, + valuesVerified: dpiTable !== null, hideLodLow: false, hideUnsupportedPollingRates: true, hideMotionSync: true, hideRippleControl: true, hideSleepCard: true, - forceShowBattery: connection === "Wireless", + forceShowBattery: false, showAdvancedSection: true, pollingNote: "Polling changes write and stick, but the echo does not yet decode a rate, so the value shown is the last one written.", statusNote: dpiTable === null ? "The mouse did not answer its settings query, so DPI could not be read." : undefined, }, - batteryPercent: battery, - batteryState: connection === "Wired" - ? (battery === 100 ? "Full" : "Charging") - : "Discharging", + batteryPercent: null, + batteryState: "Unknown", dpi: (dpiTable?.dpi.stages[0] ?? last?.dpi.stages[0] ?? 0), dpiY: dpiTable?.dpi.stagesY[0] ?? last?.dpi.stagesY[0], supportsSeparateDpiAxes: true, @@ -220,51 +226,67 @@ export class RoccatHidClient { if (stages.length !== ROCCAT_DPI_STAGE_COUNT || stagesY.length !== ROCCAT_DPI_STAGE_COUNT) { throw new RangeError("The settings write carries five stages per axis."); } - const settings: RoccatSettings = { - ...(this.lastWrite ?? DEFAULT_SETTINGS), - dpi: { stages: [...stages], stagesY: [...stagesY] }, - }; - await this.commit(settings); + await this.patchSettings( + (current) => ({ ...current, dpi: { stages: [...stages], stagesY: [...stagesY] } }), + `DPI stages ${stages.join("/")}`, + ); } async setPollingRateHz(rateHz: number): Promise { if (!ROCCAT_HZ_TO_POLLING_CODE.has(rateHz)) throw new RangeError(`Unsupported polling rate: ${rateHz} Hz.`); - const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), pollingRateHz: rateHz }; - await this.commit(settings); + await this.patchSettings((current) => ({ ...current, pollingRateHz: rateHz }), `polling rate ${rateHz} Hz`); } async setAngleSnapping(enabled: boolean): Promise { - const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), angleSnapping: enabled }; - await this.commit(settings); + await this.patchSettings((current) => ({ ...current, angleSnapping: enabled }), `angle snapping ${enabled ? "on" : "off"}`); } async setLiftOffDistance(lod: "Low" | "High"): Promise { if (!ROCCAT_LOD_TO_CODE.has(lod)) throw new RangeError(`Unsupported lift-off distance: ${lod}`); - const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), lod }; - await this.commit(settings); + await this.patchSettings((current) => ({ ...current, lod }), `lift-off distance ${lod}`); } async setDebounceMs(ms: number): Promise { - const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), debounceMs: ms }; - await this.commit(settings); + await this.patchSettings((current) => ({ ...current, debounceMs: ms }), `debounce ${ms} ms`); } async setLedTimeout(minutes: number): Promise { - const settings: RoccatSettings = { ...(this.lastWrite ?? DEFAULT_SETTINGS), ledTimeoutMinutes: minutes }; - await this.commit(settings); + await this.patchSettings((current) => ({ ...current, ledTimeoutMinutes: minutes }), `LED timeout ${minutes} min`); } /** - * Writes the replays-and-commits sequence: the 0x…50 0x14 settings write - * followed by the `…50 18 04` commit tail. + * Read the current table, patch one field, write and commit, then read the + * table back and confirm the mouse kept it — the same read-patch-write- + * confirm cycle the AJAZZ driver uses. The pre-read also preserves the + * stored DPI stages across a single-field change; if the mouse will not + * report its table, the write is refused rather than overwriting it with + * defaults. The frame is encoded before any state changes, so an invalid + * value throws without poisoning `lastWrite`. */ - private async commit(settings: RoccatSettings): Promise { - this.lastWrite = settings; + private async patchSettings( + patch: (current: RoccatSettings) => RoccatSettings, + describe: string, + ): Promise { + const current = await this.readDpiTable(); + if (!current) { + throw new Error("Could not read the mouse's current settings, so the write was refused rather than overwrite the stored DPI stages."); + } + const settings = patch({ ...(this.lastWrite ?? DEFAULT_SETTINGS), dpi: current.dpi }); + const frame = roccatEncodePerformanceWrite(settings); + const apply = roccatEncodeApply(); await this.run(async () => { - await this.write(roccatEncodePerformanceWrite(settings)); + await this.write(frame); await this.delay(50); - await this.write(roccatEncodeApply()); + await this.write(apply); }); + const confirmed = await this.readDpiTable(); + if (!confirmed) { + throw new Error(`The mouse did not answer after the write, so ${describe} could not be confirmed.`); + } + if (!sameStages(confirmed.dpi, settings.dpi)) { + throw new Error(`The mouse kept DPI ${formatStages(confirmed.dpi)} instead of the requested ${describe}.`); + } + this.lastWrite = settings; } /** @@ -295,3 +317,14 @@ function parseRgb(hex: string): [number, number, number] { const value = Number.parseInt(match[1]!, 16); return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; } + +function sameStages(a: RoccatDpiStages, b: RoccatDpiStages): boolean { + return a.stages.length === b.stages.length + && a.stagesY.length === b.stagesY.length + && a.stages.every((value, index) => value === b.stages[index]) + && a.stagesY.every((value, index) => value === b.stagesY[index]); +} + +function formatStages(dpi: RoccatDpiStages): string { + return `${dpi.stages.join("/")} (X) ${dpi.stagesY.join("/")} (Y)`; +} diff --git a/src/roccat/index.test.ts b/src/roccat/index.test.ts index f26de3fe..51e56aac 100644 --- a/src/roccat/index.test.ts +++ b/src/roccat/index.test.ts @@ -9,7 +9,6 @@ import { ROCCAT_PACKET_LENGTH, ROCCAT_PERFORMANCE_CLASS, ROCCAT_STATUS_COMMAND_ID, - roccatDecodeBattery, roccatDecodeSettingsReply, roccatEncodeApply, roccatEncodeLighting, @@ -50,11 +49,18 @@ test("the apply commit tail and query are the recorded frames", () => { test("DPI is stored per 50 and validates scale and step", () => { assert.equal(roccatStagedDpi(800), 16); assert.equal(roccatStagedDpi(1000), 20); - assert.equal(ROCCAT_DPI_MAX / ROCCAT_DPI_STEP, 380); + assert.equal(ROCCAT_DPI_MAX / ROCCAT_DPI_STEP, 255, "the advertised max fills the slot byte"); + assert.equal(roccatStagedDpi(ROCCAT_DPI_MAX), 255); assert.throws(() => roccatStagedDpi(801), RangeError); assert.throws(() => roccatStagedDpi(ROCCAT_DPI_MIN - 50), RangeError); }); +test("the advertised maximum DPI encodes without throwing", () => { + const view = roccatEncodePerformanceWrite(pad([ROCCAT_DPI_MAX, ROCCAT_DPI_MAX, ROCCAT_DPI_MAX, ROCCAT_DPI_MAX, ROCCAT_DPI_MAX])); + assert.deepEqual([...view.subarray(8, 18)], [255, 255, 255, 255, 255, 255, 255, 255, 255, 255]); + assert.throws(() => roccatEncodePerformanceWrite(pad([ROCCAT_DPI_MAX + 50, 800, 800, 800, 800])), RangeError); +}); + test("settings writes validate every driven field", () => { const settings = pad([800, 800, 800, 800, 800]); const view = roccatEncodePerformanceWrite({ @@ -124,14 +130,10 @@ test("an all-zero stage table decodes as no-data, the sleeper echo", () => { assert.deepEqual(roccatDecodeSettingsReply(one)?.dpi.stages, [800, 0, 0, 0, 0]); }); -test("battery decodes from the status echo recorded on hardware", () => { +test("the status probe reply is recognized but never decoded as a battery percent", () => { assert.equal(roccatIsStatusReply(new Uint8Array([0x90, 0x0a])), true); + assert.equal(roccatIsStatusReply(new Uint8Array([0x90, 0x0a, 0x04, 0x00, 0x00, 0x1e])), true); assert.equal(roccatIsStatusReply(new Uint8Array([0x10, 0x50, 0x03])), false); - // The echo every poll of this unit returned; byte 5 held 0x1e. - assert.equal(roccatDecodeBattery(new Uint8Array([0x90, 0x0a, 0x04, 0x00, 0x00, 0x1e])), 0x1e); - assert.equal(roccatDecodeBattery(new Uint8Array([0x90, 0x0a, 0x04, 0x00, 0x00])), null); - assert.equal(roccatDecodeBattery(new Uint8Array([0x90, 0x0a, 0x04, 0x00, 0x00, 101])), null); - assert.equal(roccatDecodeBattery(new Uint8Array([0x10, 0x50, 0x03])), null); assert.equal(ROCCAT_STATUS_COMMAND_ID, 0x90); }); diff --git a/src/roccat/index.ts b/src/roccat/index.ts index a5f1c6b4..116f1c00 100644 --- a/src/roccat/index.ts +++ b/src/roccat/index.ts @@ -15,7 +15,9 @@ * - `0x10 0x50 ` 鈥?performance class: the settings write / query (0x14), * the commit tail `0x10 0x50 0x18 0x04`, and the commit (0x19); * - `0x10 0x10 ` 鈥?the lighting write (`0x0b` + two RGB zones); - * - `0x90 0x0a` 鈥?the unnamed status probe, answered `0x90 0x0a 04 00 00 `. + * - `0x90 0x0a` 鈥?the unnamed status probe, answered `0x90 0x0a 04 00 00 鈥?` + * with a body whose meaning is unresolved (see the note by + * `roccatIsStatusReply`). * * This is the protocol the vendor software replays; the DPI table write, the * commit tail, the status probe and the lighting write were exercised on this @@ -86,9 +88,14 @@ export const ROCCAT_HZ_TO_POLLING_CODE: ReadonlyMap = new Map([ [125, 0x03], ]); -/** DPI is stored per 50. Vendor scale: 50 鈥?19,000. */ +/** + * DPI is stored per 50 in a single byte slot, so the encodable ceiling is + * 255 x 50 = 12,750. The vendor software advertises higher numbers, but that + * encoding was never captured and does not fit this byte, so it is not + * claimed here. + */ export const ROCCAT_DPI_MIN = 50; -export const ROCCAT_DPI_MAX = 19000; +export const ROCCAT_DPI_MAX = 12750; export const ROCCAT_DPI_STEP = 50; /** The settings write carries five stages of each axis. */ export const ROCCAT_DPI_STAGE_COUNT = 5; @@ -128,8 +135,9 @@ export interface RoccatSettings { } /** - * DPI value to its 50-step slot byte. The wire carries dpi/50; 19,000/50 is - * 380, which fits, so the whole documented scale encodes losslessly. + * DPI value to its 50-step slot byte. The wire carries dpi/50 in one byte, so + * the encodable ceiling is 255 x 50 = 12,750 DPI; higher vendor numbers are + * not reachable through this slot and were never captured. */ export function roccatStagedDpi(dpi: number): number { if (!Number.isInteger(dpi / ROCCAT_DPI_STEP)) { @@ -243,26 +251,12 @@ export function roccatIsStatusReply(reply: Uint8Array): boolean { return reply.length >= 2 && reply[0] === ROCCAT_STATUS_COMMAND[0] && reply[1] === ROCCAT_STATUS_COMMAND[1]; } -export interface RoccatStatus { - batteryPercent: number | null; -} - -/** - * The one number the status echo plausibly reports for the UI. On this unit - * every poll answered `90 0a 04 00 00 1e 00 00 1e` 鈥?bytes 5 and 10 - * both held 0x1e while bytes 8鈥? carried a slowly decreasing u16 (2720 鈫? * 2714 鈫?2711, shaped like an estimated-use-remaining counter). Neither byte - * is confirmed as the battery percent: signal strength and elapsed session - * time fit the same bytes. Byte 5 is decoded here the same way SignalRGB - * reads this frame ("batt/signal guessing" is its own comment); its byte-moves - * (0x1e -> 0x14) stay unexplained, so the mapping stays flagged until someone - * watches it drain. Values above 100 are refused rather than wrapped. - */ -export function roccatDecodeBattery(reply: Uint8Array): number | null { - if (!roccatIsStatusReply(reply)) return null; - const percent = reply[5]; - if (percent === undefined || percent > 100) return null; - return percent; -} +// The status echo `90 0a 04 00 00 1e 00 00 1e` was recorded on this +// unit, but byte 5's meaning is unresolved: it moved 0x1e -> 0x14 across +// sessions while the trailing u16 decremented (2720 -> 2884), so a battery +// percent, a signal strength and an elapsed counter all fit. It is +// deliberately NOT decoded as a percent; no battery reading is surfaced until +// a capture establishes the mapping. /** * Reply to a settings query / the echo after a write, on this unit: From a8207b37864e4f34cd97ece3733dda73c1718465 Mon Sep 17 00:00:00 2001 From: Kissshot-2003 <311225363+Kissshot-2003@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:26:57 +0800 Subject: [PATCH 3/6] fix(roccat): keep the settings cycle atomic and tolerate the sleeper echo The read-patch-write-confirm cycle ran as three separate queue tasks, so two concurrent setters could interleave and one pre-read could observe the other's half-applied write. The whole cycle is now a single queue task, and its reads call transact directly -- a nested run() would deadlock behind the task it is already in. An asleep mouse answers the settings query with an empty body, which made every setter refuse. The stages now fall back to the driver's own last write; only a first write with no readable table is refused, because inventing the stages would silently re-scale the sensor. When the table could not be read the confirm step is skipped, and readStatus keeps deriving valuesVerified from whether a table was readable. Also correct the status note: the trailing u16 increased (2720 -> 2884). --- src/drivers/roccat/hid.test.ts | 19 +++++-- src/drivers/roccat/hid.ts | 95 ++++++++++++++++++++-------------- src/roccat/index.ts | 2 +- 3 files changed, 72 insertions(+), 44 deletions(-) diff --git a/src/drivers/roccat/hid.test.ts b/src/drivers/roccat/hid.test.ts index 06459735..31af06ff 100644 --- a/src/drivers/roccat/hid.test.ts +++ b/src/drivers/roccat/hid.test.ts @@ -32,8 +32,8 @@ function fakeDevice(options: FakeOptions = {}) { usagePage = ROCCAT_CONFIG_USAGE_PAGE, usage = ROCCAT_CONFIG_USAGE, locksTable = false, - silent = false, } = options; + let silent = options.silent ?? false; let stored = Uint8Array.from(options.table ?? STORED_TABLE); const sent: Uint8Array[] = []; let listener: ((event: HIDInputReportEvent) => void) | null = null; @@ -67,7 +67,7 @@ function fakeDevice(options: FakeOptions = {}) { } }, } as unknown as HIDDevice; - return { device, sent, storedTable: () => [...stored] }; + return { device, sent, storedTable: () => [...stored], setSilent: (value: boolean) => { silent = value; } }; } test("isSupported needs the ROCCAT VID, a known PID and the 0xFF00 vendor collection", () => { @@ -151,9 +151,20 @@ test("a mouse that ignores the write is not reported as success", async () => { ); }); -test("a setter refuses to guess when the mouse will not report its table", async () => { +test("a first write with an unreadable table is refused rather than invent stages", async () => { const { device } = fakeDevice({ silent: true }); - await assert.rejects(() => new RoccatHidClient(device).setPollingRateHz(125), /refused/); + await assert.rejects(() => new RoccatHidClient(device).setPollingRateHz(125), /Wake the mouse/); +}); + +test("a setter still writes when the mouse stops answering, reusing its own last table", async () => { + const { device, sent, setSilent } = fakeDevice(); + const client = new RoccatHidClient(device); + await client.setPollingRateHz(500); + setSilent(true); // the mouse sleeps and answers the query with an empty body + await client.setPollingRateHz(125); + const write = sent.filter((frame) => frame[1] === 0x50 && frame[2] === 0x14 && frame[4] === 0x01).at(-1); + assert.equal(write![5], 0x03, "125 Hz -> 0x03"); + assert.deepEqual([...write!.subarray(8, 18)], STORED_TABLE, "the stages come from the last write, not from defaults"); }); test("setLighting writes one frame with both zones", async () => { diff --git a/src/drivers/roccat/hid.ts b/src/drivers/roccat/hid.ts index 362bc177..b3ee0803 100644 --- a/src/drivers/roccat/hid.ts +++ b/src/drivers/roccat/hid.ts @@ -38,14 +38,17 @@ import { * Writes follow the repo's read-patch-write-confirm cycle (as in the AJAZZ * driver): the settings write replaces the whole block, so every setter reads * the mouse's current table first, patches the one field, writes and commits, - * then reads the table back and refuses to report success unless the mouse - * kept it. The pre-read is also what preserves the stored DPI stages: a - * non-DPI setter (polling rate, LOD, …) would otherwise substitute defaults - * and silently re-scale the mouse. What stays deliberately unfolded: the - * echo's polling/LOD/debounce positions are not established, so the UI - * surfaces those values only from the driver's own last write, exactly how - * the Razer driver handles write-only fields, and no battery reading is - * surfaced at all because the status byte's mapping is unresolved. + * then reads the table back and refuses to report success unless the mouse + * kept it. The pre-read is also what preserves the stored DPI stages: a + * non-DPI setter (polling rate, LOD, …) would otherwise substitute defaults + * and silently re-scale the mouse. An asleep mouse answers the query with an + * empty body, so a setter falls back to the last table this driver wrote, and + * only a write with no table at all (before the first successful one) is + * refused. What stays deliberately unfolded: the echo carries no established + * polling/LOD/angle/debounce positions, so the UI surfaces those values only + * from the driver's own last write, exactly how the Razer driver handles + * write-only fields, and no battery reading is surfaced at all because the + * status byte's mapping is unresolved. */ export class RoccatHidClient { readonly device: HIDDevice; @@ -111,15 +114,6 @@ export class RoccatHidClient { return next; } - /** - * A read runs through the same queue as writes: the settings query and the - * settings write share class byte 1 (0x50), so an unserialized read could - * accept a concurrent write's echo as its reply. - */ - private read(payload: Uint8Array): Promise { - return this.run(() => this.transact(payload)); - } - private delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } @@ -158,9 +152,23 @@ export class RoccatHidClient { return echo; } - /** The DPI table read. Null when the mouse never answers the query. */ + /** + * The DPI table read. Null when the mouse never answers the query. It runs + * through the same queue as writes: the settings query and the settings + * write share class byte 1 (0x50), so an unserialized read could accept a + * concurrent write's echo as its reply. + */ async readDpiTable(): Promise { - const echo = await this.read(roccatEncodeSettingsQuery()); + return this.run(() => this.readTable()); + } + + /** + * Sends the settings query and decodes the echo, without queueing. For + * callers already inside a queue task: a nested `run()` from inside + * `patchSettings` would deadlock behind the task it is already in. + */ + private async readTable(): Promise { + const echo = await this.transact(roccatEncodeSettingsQuery()); return echo ? roccatDecodeSettingsReply(echo) : null; } @@ -257,36 +265,45 @@ export class RoccatHidClient { /** * Read the current table, patch one field, write and commit, then read the * table back and confirm the mouse kept it — the same read-patch-write- - * confirm cycle the AJAZZ driver uses. The pre-read also preserves the - * stored DPI stages across a single-field change; if the mouse will not - * report its table, the write is refused rather than overwriting it with - * defaults. The frame is encoded before any state changes, so an invalid - * value throws without poisoning `lastWrite`. + * confirm cycle the AJAZZ driver uses. The frame is encoded before any state + * changes, so an invalid value throws without poisoning `lastWrite`. + * + * An asleep mouse answers the settings query with an empty body, so the + * stages come from the live read when there is one and otherwise from this + * driver's own last write. A first-ever write has neither and is refused: + * the write replaces the whole block, so inventing the stages would silently + * re-scale the sensor. When the table could not be read the write still goes + * out, but the confirm step is skipped — there is nothing to compare + * against — and `readStatus` keeps deriving `valuesVerified` from whether a + * table was readable, not from the write. + * + * The whole cycle is a single queue task, and its reads call `transact` + * directly rather than `readDpiTable`: two concurrent setters must not + * interleave read/patch/write, or one setter's pre-read could observe the + * other's half-applied write. A nested `run()` here would instead deadlock. */ private async patchSettings( patch: (current: RoccatSettings) => RoccatSettings, describe: string, ): Promise { - const current = await this.readDpiTable(); - if (!current) { - throw new Error("Could not read the mouse's current settings, so the write was refused rather than overwrite the stored DPI stages."); - } - const settings = patch({ ...(this.lastWrite ?? DEFAULT_SETTINGS), dpi: current.dpi }); - const frame = roccatEncodePerformanceWrite(settings); - const apply = roccatEncodeApply(); await this.run(async () => { + const current = await this.readTable(); + const dpi = current?.dpi ?? this.lastWrite?.dpi; + if (!dpi) { + throw new Error("Could not read the mouse's current settings (an asleep mouse answers with an empty table), so the write was refused rather than overwrite the stored DPI stages. Wake the mouse and try again."); + } + const settings = patch({ ...(this.lastWrite ?? DEFAULT_SETTINGS), dpi }); + const frame = roccatEncodePerformanceWrite(settings); + const apply = roccatEncodeApply(); await this.write(frame); await this.delay(50); await this.write(apply); + const confirmed = await this.readTable(); + if (confirmed && !sameStages(confirmed.dpi, settings.dpi)) { + throw new Error(`The mouse kept DPI ${formatStages(confirmed.dpi)} instead of the requested ${describe}.`); + } + this.lastWrite = settings; }); - const confirmed = await this.readDpiTable(); - if (!confirmed) { - throw new Error(`The mouse did not answer after the write, so ${describe} could not be confirmed.`); - } - if (!sameStages(confirmed.dpi, settings.dpi)) { - throw new Error(`The mouse kept DPI ${formatStages(confirmed.dpi)} instead of the requested ${describe}.`); - } - this.lastWrite = settings; } /** diff --git a/src/roccat/index.ts b/src/roccat/index.ts index 116f1c00..4fb7b3b8 100644 --- a/src/roccat/index.ts +++ b/src/roccat/index.ts @@ -253,7 +253,7 @@ export function roccatIsStatusReply(reply: Uint8Array): boolean { // The status echo `90 0a 04 00 00 1e 00 00 1e` was recorded on this // unit, but byte 5's meaning is unresolved: it moved 0x1e -> 0x14 across -// sessions while the trailing u16 decremented (2720 -> 2884), so a battery +// sessions while the trailing u16 increased (2720 -> 2884), so a battery // percent, a signal strength and an elapsed counter all fit. It is // deliberately NOT decoded as a percent; no battery reading is surfaced until // a capture establishes the mapping. From 5452ade8c4eeed76525662098522f44a44bc1d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B4=9B=E6=A9=99?= Date: Thu, 15 Oct 2026 20:50:55 +0800 Subject: [PATCH 4/6] docs(roccat): repair mojibake in the codec comments and fill in missing docstrings Several lines in src/roccat/index.ts had been round-tripped through a non-UTF-8 code page, so the dashes and ellipses read as mojibake. Restore the em/en dashes and ellipses, matching the same phrases in the driver and in the testing docs. Document the remaining exported functions and class methods that had no docstring, which is what the docstring-coverage pre-merge check flags. npm run check: 2503 tests, 0 failures. --- src/drivers/roccat/hid.ts | 21 +++++++++++++++++++++ src/roccat/index.ts | 29 ++++++++++++++++------------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/drivers/roccat/hid.ts b/src/drivers/roccat/hid.ts index b3ee0803..5952104e 100644 --- a/src/drivers/roccat/hid.ts +++ b/src/drivers/roccat/hid.ts @@ -55,10 +55,12 @@ export class RoccatHidClient { private queue: Promise = Promise.resolve(); private lastWrite: RoccatSettings | null = null; + /** Wraps a device the registry has already matched. */ constructor(device: HIDDevice) { this.device = device; } + /** Matches a Kone Pro (Air) that exposes the 0xFF00:0x0001 vendor collection. */ static isSupported(device: HIDDevice): boolean { if (device.vendorId !== ROCCAT_VENDOR_ID) return false; if (!ROCCAT_PRODUCT_IDS.includes(device.productId)) return false; @@ -68,39 +70,48 @@ export class RoccatHidClient { return device.collections.some(search); } + /** No discrete list: the five-stage editor drives DPI instead. */ getDpiOptions(): number[] { return []; } + /** Opens the device if the vendor link is still closed. */ async open(): Promise { if (!this.device.opened) await this.device.open(); } + /** Forgets the cached table and closes the device. */ async close(): Promise { this.lastWrite = null; if (this.device.opened) await this.device.close(); } + /** Product label for the UI. */ displayName(): string { return ROCCAT_PRODUCT_NAMES.get(this.device.productId) ?? "ROCCAT mouse"; } + /** Brand tag reported in MouseStatus. */ deviceBrand(): MouseStatus["brand"] { return "ROCCAT"; } + /** Wireless on the dongle pid, wired otherwise. */ connectionType(): "Wired" | "Wireless" { return this.device.productId === ROCCAT_KONE_PRO_AIR_WIRELESS_PID ? "Wireless" : "Wired"; } + /** The four rates the polling byte can encode. */ getSupportedPollingRates(): number[] { return [...ROCCAT_HZ_TO_POLLING_CODE.keys()]; } + /** Largest debounce the settings byte accepts. */ getDebounceMaxMs(): number { return ROCCAT_DEBOUNCE_MAX_MS; } + /** Every whole-millisecond debounce in range. */ getDebounceOptions(): number[] { return Array.from( { length: ROCCAT_DEBOUNCE_MAX_MS - ROCCAT_DEBOUNCE_MIN_MS + 1 }, @@ -108,12 +119,14 @@ export class RoccatHidClient { ); } + /** Serializes device work so transactions cannot interleave. */ private run(task: () => Promise): Promise { const next = this.queue.then(task, task); this.queue = next.catch(() => undefined); return next; } + /** Sleeps for the echo window. */ private delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } @@ -240,24 +253,29 @@ export class RoccatHidClient { ); } + /** Setter for the polling byte; rejects rates the table lacks. */ async setPollingRateHz(rateHz: number): Promise { if (!ROCCAT_HZ_TO_POLLING_CODE.has(rateHz)) throw new RangeError(`Unsupported polling rate: ${rateHz} Hz.`); await this.patchSettings((current) => ({ ...current, pollingRateHz: rateHz }), `polling rate ${rateHz} Hz`); } + /** Setter for the angle-snapping flag. */ async setAngleSnapping(enabled: boolean): Promise { await this.patchSettings((current) => ({ ...current, angleSnapping: enabled }), `angle snapping ${enabled ? "on" : "off"}`); } + /** Setter for the lift-off distance. */ async setLiftOffDistance(lod: "Low" | "High"): Promise { if (!ROCCAT_LOD_TO_CODE.has(lod)) throw new RangeError(`Unsupported lift-off distance: ${lod}`); await this.patchSettings((current) => ({ ...current, lod }), `lift-off distance ${lod}`); } + /** Setter for the debounce byte. */ async setDebounceMs(ms: number): Promise { await this.patchSettings((current) => ({ ...current, debounceMs: ms }), `debounce ${ms} ms`); } + /** Setter for the LED-off timeout. */ async setLedTimeout(minutes: number): Promise { await this.patchSettings((current) => ({ ...current, ledTimeoutMinutes: minutes }), `LED timeout ${minutes} min`); } @@ -328,6 +346,7 @@ const DEFAULT_SETTINGS: RoccatSettings = { dpi: { stages: [400, 800, 1200, 1600, 3200], stagesY: [400, 800, 1200, 1600, 3200] }, }; +/** Parses `#rrggbb` into an RGB triple. */ function parseRgb(hex: string): [number, number, number] { const match = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); if (!match) throw new RangeError(`Colour must be #rrggbb, received ${hex}`); @@ -335,6 +354,7 @@ function parseRgb(hex: string): [number, number, number] { return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; } +/** True when both stage tables match on both axes. */ function sameStages(a: RoccatDpiStages, b: RoccatDpiStages): boolean { return a.stages.length === b.stages.length && a.stagesY.length === b.stagesY.length @@ -342,6 +362,7 @@ function sameStages(a: RoccatDpiStages, b: RoccatDpiStages): boolean { && a.stagesY.every((value, index) => value === b.stagesY[index]); } +/** Renders a stage table for error messages. */ function formatStages(dpi: RoccatDpiStages): string { return `${dpi.stages.join("/")} (X) ${dpi.stagesY.join("/")} (Y)`; } diff --git a/src/roccat/index.ts b/src/roccat/index.ts index 4fb7b3b8..30bd440b 100644 --- a/src/roccat/index.ts +++ b/src/roccat/index.ts @@ -3,30 +3,30 @@ * wired) and sibling Kone Pro firmware. * * Transport: the mouse speaks on the composite's vendor interface (usage page - * 0xFF00, usage 0x0001 鈥?interface 2 of the dongle, interface 1 wired) with + * 0xFF00, usage 0x0001 — interface 2 of the dongle, interface 1 wired) with * plain unnumbered output reports. WebHID callers send * `sendReport(0, payload64)`; the mouse answers on the same interface's - * interrupt-IN stream. There is no feature report here 鈥?a full 0x00鈥?xFF + * interrupt-IN stream. There is no feature report here — a full 0x00–0xFF * GET sweep on this unit answered nothing (2026-10-14, Windows node-hid, * `captures/roccat-kone-pro-air/`). * * Payload frame layout (device coordinates, ahead of no report id): * - * - `0x10 0x50 ` 鈥?performance class: the settings write / query (0x14), + * - `0x10 0x50 ` — performance class: the settings write / query (0x14), * the commit tail `0x10 0x50 0x18 0x04`, and the commit (0x19); - * - `0x10 0x10 ` 鈥?the lighting write (`0x0b` + two RGB zones); - * - `0x90 0x0a` 鈥?the unnamed status probe, answered `0x90 0x0a 04 00 00 鈥?` + * - `0x10 0x10 ` — the lighting write (`0x0b` + two RGB zones); + * - `0x90 0x0a` — the unnamed status probe, answered `0x90 0x0a 04 00 00 — ` * with a body whose meaning is unresolved (see the note by * `roccatIsStatusReply`). * * This is the protocol the vendor software replays; the DPI table write, the * commit tail, the status probe and the lighting write were exercised on this * unit (see `captures/roccat-kone-pro-air/`). What is proven on hardware: the - * frames are accepted, the background status stream answers `90 0a 04 鈥, and + * frames are accepted, the background status stream answers `90 0a 04 …`, and * a settings echo carried a full stage table on one session with the mouse * awake. What is not: a byte-precise before/after proof that the stage table * write applies another read-back than the sleeper echoes an empty body for. - * The fields carry their known/unknown state through 鈥?unknown bytes stay + * The fields carry their known/unknown state through — unknown bytes stay * unknown rather than guessed. */ @@ -159,7 +159,7 @@ export function roccatStagedDpi(dpi: number): number { * 00 80 00 * ``` * - * The DPI slots land at payload bytes 8..17 鈥?even bytes for the stage list + * The DPI slots land at payload bytes 8..17 — even bytes for the stage list * SignalRGB drives from its DPI entries, odd bytes for the other axis. On this * unit the axis-byte assignment reads back symmetrically (both lists held the * same five values from the factory), so the codec writes both axes and marks @@ -223,13 +223,14 @@ export function roccatEncodePerformanceWrite(settings: RoccatSettings): Uint8Arr /** The commit tail the vendor software sends after a settings write. */ export const ROCCAT_APPLY_FRAME: readonly number[] = [ROCCAT_FRAME_PREFIX, ROCCAT_PERFORMANCE_CLASS, ROCCAT_PERFORMANCE_COMMAND.applyGroup, 0x04]; +/** The `10 50 18 04` apply frame the vendor software sends after a settings write. */ export function roccatEncodeApply(): Uint8Array { const view = new Uint8Array(ROCCAT_PACKET_LENGTH); [...ROCCAT_APPLY_FRAME].forEach((byte, index) => { view[index] = byte; }); return view; } -/** The status probe, answered `90 0a 鈥 on the input stream. */ +/** The status probe, answered `90 0a …` on the input stream. */ export function roccatEncodeStatus(): Uint8Array { const view = new Uint8Array(ROCCAT_PACKET_LENGTH); view[0] = ROCCAT_STATUS_COMMAND[0]!; @@ -237,7 +238,7 @@ export function roccatEncodeStatus(): Uint8Array { return view; } -/** The empty settings query 鈥?a 0x14 frame with no arguments. */ +/** The empty settings query — a 0x14 frame with no arguments. */ export function roccatEncodeSettingsQuery(): Uint8Array { const view = new Uint8Array(ROCCAT_PACKET_LENGTH); view[0] = ROCCAT_FRAME_PREFIX; @@ -246,7 +247,7 @@ export function roccatEncodeSettingsQuery(): Uint8Array { return view; } -/** Reply to the status probe recorded on this unit: `90 0a 04 00 00 = 2 && reply[0] === ROCCAT_STATUS_COMMAND[0] && reply[1] === ROCCAT_STATUS_COMMAND[1]; } @@ -260,16 +261,17 @@ export function roccatIsStatusReply(reply: Uint8Array): boolean { /** * Reply to a settings query / the echo after a write, on this unit: - * `10 50 03 00 00 鈥. The class + * `10 50 03 00 00 …`. The class * byte 2 was 0x03 on every observed variant; frames that echo the driven * command (`0x14` writes) carry it at byte 2 as well. Only the frame prefix - * and the DPI table are decoded 鈥?the remaining bytes (the device word, the + * and the DPI table are decoded — the remaining bytes (the device word, the * sequence marker, LOD/angle positions in the echo) are not established. */ export interface RoccatSettingsReply { dpi: RoccatDpiStages; } +/** Decodes a settings echo into its DPI table, or null when the reply is not a settings frame or carries an empty (asleep) body. */ export function roccatDecodeSettingsReply(reply: Uint8Array): RoccatSettingsReply | null { if ( reply.length < 18 @@ -320,6 +322,7 @@ export const ROCCAT_LIGHTING_PREFIX: readonly number[] = [ 0x06, ]; +/** The lighting write frame: one raw RGB triple per zone. */ export function roccatEncodeLighting(left: readonly [number, number, number], right: readonly [number, number, number]): Uint8Array { const view = new Uint8Array(ROCCAT_PACKET_LENGTH); [...ROCCAT_LIGHTING_PREFIX].forEach((byte, index) => { view[index] = byte; }); From 414e6bb5b481ebb1572e194685ad47fbd80f7eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B4=9B=E6=A9=99?= Date: Thu, 15 Oct 2026 21:05:21 +0800 Subject: [PATCH 5/6] docs(roccat): repair mojibake in the codec comments and fill in missing docstrings Several lines in src/roccat/index.ts had been round-tripped through a non-UTF-8 code page, so the dashes and ellipses read as mojibake. Restore the em/en dashes and ellipses, matching the same phrases in the driver and in the testing docs. Document the remaining exported functions and class methods that had no docstring, which is what the docstring-coverage pre-merge check flags. npm run check: 2503 tests, 0 failures. --- src/drivers/roccat/hid.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/drivers/roccat/hid.ts b/src/drivers/roccat/hid.ts index 5952104e..8fc9ecd2 100644 --- a/src/drivers/roccat/hid.ts +++ b/src/drivers/roccat/hid.ts @@ -211,7 +211,7 @@ export class RoccatHidClient { hideSleepCard: true, forceShowBattery: false, showAdvancedSection: true, - pollingNote: "Polling changes write and stick, but the echo does not yet decode a rate, so the value shown is the last one written.", + pollingNote: "Polling is write-only here: the echo does not yet decode a rate, so the value shown is the last one written, not a value read back.", statusNote: dpiTable === null ? "The mouse did not answer its settings query, so DPI could not be read." : undefined, From d54a7e26f8b3ef0d5b17178aa8f5bcdbe8c37009 Mon Sep 17 00:00:00 2001 From: Kissshot-2003 <311225363+Kissshot-2003@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:56:26 +0800 Subject: [PATCH 6/6] docs(roccat): record the profile reply pages and the unverified write path Second hardware session, this one with Swarm installed and driving the mouse: - The five `90 00 26` reply pages are the five onboard profiles: profile index at b10, polling code at b11, five u16 DPI slots at b14-23, lift-off at b24, LED timeout at b27. Swarm's own DPI card and polling radios line up with those fields, and switching a profile's polling in the UI moved exactly the polling byte of that profile's page. - The reply page is the SignalRGB plugin's write frame shifted right by five bytes, so the stored settings block is five u16 stage slots rather than two interleaved byte lists. The testing doc now marks the earlier interleaved reading as superseded. - Hand-sent settings writes on interface 2 are accepted and echoed but never changed a stored stage table, no HID interface exposes the vendor's settings writes, and the vendor device DLL contains no settings-write code. The write path is recorded as unverified on hardware, not verified. Docs and captures only; the codec still encodes the earlier interleaved guess, and switching it to the plugin layout is the next step. --- captures/roccat-kone-pro-air/README.md | 11 ++- .../profiles-and-write-path-2026-10-16.txt | 99 +++++++++++++++++++ docs/roccat-kone-pro-air-testing.md | 83 +++++++++++----- 3 files changed, 164 insertions(+), 29 deletions(-) create mode 100644 captures/roccat-kone-pro-air/profiles-and-write-path-2026-10-16.txt diff --git a/captures/roccat-kone-pro-air/README.md b/captures/roccat-kone-pro-air/README.md index 36502013..8c5b21e3 100644 --- a/captures/roccat-kone-pro-air/README.md +++ b/captures/roccat-kone-pro-air/README.md @@ -2,10 +2,12 @@ Hardware reference material for `src/roccat/` and `src/drivers/roccat/`. Captured from this reporter's own unit — one ROCCAT Kone Pro Air on its -Stellar Wireless dongle (USB `1e7d:2c8e`), Windows, driverless node-hid -session on 2026-10-14 with ROCCAT Swarm fully uninstalled. The mouse's -product string carries no serial number, the vendor frames carry no account -data, and no capture here includes personal data. +Stellar Wireless dongle (USB `1e7d:2c8e`), Windows. The first session +(2026-10-14) was a driverless node-hid session with ROCCAT Swarm +uninstalled; a later session (2026-10-16, the last file in the table) ran +with Swarm installed and operating the mouse. The mouse's product string +carries no serial number, the vendor frames carry no account data, and no +capture here includes personal data. | File | What it is | |-|-| @@ -13,6 +15,7 @@ data, and no capture here includes personal data. | `feature-sweep.txt` | The negative result: GET for every report id 0x00–0xFF on every interface times out or answers zero. The whole protocol lives on output reports, which is why the frames are `sendReport`-shaped. | | `session-2026-10-14.txt` | A byte-precise transcript: status probe, settings query, the vendor-replayed settings write + commit tail, a second query, the lighting write, and the echoes to each. One session earlier the query echo carried a full stage table (the mouse awake); in this session the mouse was idle and the same frame echoed an empty body — both are in the file. | | `transcript-earlier-session.txt` | The earlier, still byte-precise outline of the same frames from the discovery session, included because it is the capture where the stage table WAS returned (`10 50 03 … 10 10 18 18 20 20 40 40 64 64 01 02 00 00` — both slot lists identical, five entries 800/1200/1600/3200/5000, which matches this unit's then-current Swarm settings rather than factory defaults). | +| `profiles-and-write-path-2026-10-16.txt` | A second session, this one with Swarm installed and driving the mouse. Identifies the five `90 00 26` reply pages as the five onboard profiles (polling at `b11`, stage table at `b14-23`, lift-off at `b24`), shows a UI polling change moving exactly that byte, aligns the read page to the plugin's write frame at +5, and records that hand-sent settings writes on interface 2 are echoed but never change the stored tables. | ## What settles diff --git a/captures/roccat-kone-pro-air/profiles-and-write-path-2026-10-16.txt b/captures/roccat-kone-pro-air/profiles-and-write-path-2026-10-16.txt new file mode 100644 index 00000000..dad32bf5 --- /dev/null +++ b/captures/roccat-kone-pro-air/profiles-and-write-path-2026-10-16.txt @@ -0,0 +1,99 @@ +# Per-profile read pages and the write path (2026-10-16) + +Second session on the same unit, this time with ROCCAT Swarm installed and +driving the mouse, observed from a passive node-hid listener on interface 2 +while the Swarm UI was operated. It settles what the earlier captures could +not: where the settings block lives in the reply stream, and what our +user-mode writes actually do to the mouse. + +## The five `90 00 26` reply pages are the five onboard profiles + +Swarm exposes five profile slots ("配置文件组1"…"配置文件组5"). When the +device page attaches, the mouse streams five `90 00 26` replies, one per +profile. Fields, aligned on the page's own header `90 00 26 00 00 04 20 00 +92 2c`: + +```text +b10 profile index (01..05) +b11 that profile's polling code (00=1000 Hz .. 03=125 Hz, same dict as the write) +b12 00 +b13 00 empty / 01 valid / 02 the profile the UI is editing +b14-23 five DPI stages, u16 little-endian, 50/unit (e.g. 08 00 = 400 DPI) +b24 lift-off distance code (06 = Low) +b25 00 +b26 80 / 06 / 00 (profile-dependent; unresolved) +b27 LED timeout minutes (0f = 15) +``` + +Captured order (1000 Hz era, mouse awake, profiles 2..5 configured, profile 1 +blank): + +```text +90 00 26 00 00 04 20 00 92 2c 02 00 00 02 08 00 10 00 15 00 20 00 40 00 06 00 06 0f ... +90 00 26 00 00 04 20 00 92 2c 03 00 00 01 08 00 10 00 18 00 20 00 40 00 06 00 00 0f ... +90 00 26 00 00 04 20 00 92 2c 04 00 00 01 08 00 10 00 18 00 20 00 40 00 06 00 00 05 ... +90 00 26 00 00 04 20 00 92 2c 05 00 00 01 08 00 10 00 18 00 20 00 40 00 06 00 00 05 ... +90 00 26 00 00 04 20 00 92 2c 01 00 00 00 00 00 00 00 00 00 00 00 00 00 06 00 00 0f ... +``` + +The profile the UI was editing (index 2) carries the stages **400 / 800 / +1050 / 1600 / 3200** — byte-for-byte the values the UI's own "DPI 切换开关" +card displays (`400`, `800`, `1050`, `1600`, `3200`). The 1050 is the unit +owner's own value, not a side effect of any probe. + +## UI edits land in these pages (one-session differential) + +With the listener running, the UI was used to switch the edited profile's +polling rate 1000 → 125 Hz. Restarting Swarm re-triggered the stream, and the +same profile's page changed exactly one byte: + +```text +before: 90 00 26 00 00 04 20 00 92 2c 02 00 00 02 08 00 10 00 15 00 20 00 40 00 ... +after: 90 00 26 00 00 04 20 00 92 2c 02 03 00 02 08 00 10 00 15 00 20 00 40 00 ... +``` + +`b11` 00 → 03 (125 Hz). That identifies the polling field and proves the +vendor's own write path reaches the mouse. + +## Read page vs. write frame: a +5 byte shift + +Lining the page up with the SignalRGB plugin's write frame (`Plugins/Roccat/ +Roccat_Kone_Pro_Air_Mouse.js`) puts every established field at write offset ++ 5: + +```text +write frame 10 50 14 00 01 00 00 80 00 +read page 90 00 26 00 00 04 20 00 92 2c 00 00 ... +``` + +So the stored settings block is the plugin's layout, five u16 stage slots +(not two interleaved byte lists), and the page is that block shifted right by +five bytes behind a ten-byte read header. + +## What our own writes did + +- Frames sent by hand on interface 2 — the plugin layout with stage 1 set to + 500 (u16 `0a 00`) and, separately, the two-interleaved-lists shape — were + **accepted and echoed** (`50 14 03 00 ec fa`), but a fresh stream after each + showed **no profile's stage 1 at 500**. The stored tables were unchanged. +- On this unit the vendor writes are not visible on any HID interface at all: + a listener on interfaces 0/1/2/3 during UI edits captured the UI's read + polling loops (`10 71 03`, `10 72 03`, `10 11 03`, `10 50 03`) but no + settings write on any of them. +- The vendor's device DLL (`data\2C8E\2C8E.dll`) is a hidapi fork whose + `hid_write` is called in exactly four places, all in the calibration + module; there is no settings-write code in it. + +Read together: on this unit a settings write from a plain user-mode HID +handle is acknowledged but not applied, and the vendor applies settings +through a channel a user-mode capture cannot see. The driver's write frames +are therefore best described as **unverified on hardware**, not verified. + +## Backing files + +Raw listener logs from this session (not shipped here): the awake stream, the +125 Hz differential, and the status probes. The status probe while the mouse +was asleep answers `90 0a 03 00 ec fa 0d 00` with an all-zero body, versus +`90 0a 04 00 00 64 …` when awake — the same empty/no-data dichotomy the +settings echo shows, and why the Swarm device page sits on its loading +spinner and shows placeholder 0 % battery until the mouse answers. diff --git a/docs/roccat-kone-pro-air-testing.md b/docs/roccat-kone-pro-air-testing.md index d4fcfa9c..f6e334e0 100644 --- a/docs/roccat-kone-pro-air-testing.md +++ b/docs/roccat-kone-pro-air-testing.md @@ -7,10 +7,12 @@ in `captures/roccat-kone-pro-air/`. ## Device One ROCCAT Kone Pro Air, firmware as shipped, wireless mode on its Stellar -Wireless dongle (`1e7d:2c8e`). ROCCAT Swarm was never installed on the host, -so every byte on the control channel in the captures is a probe or a -vendor-replayed frame, not Swarm traffic. Wired mode (`1e7d:2c92`) is -mapped from the same protocol family but was not exercised on this unit. +Wireless dongle (`1e7d:2c8e`). The first capture session ran with ROCCAT +Swarm uninstalled, so the bytes there are probes and vendor-replayed frames. +A second session (`captures/roccat-kone-pro-air/profiles-and-write-path-2026-10-16.txt`) +ran with Swarm installed and driving the mouse, to watch the vendor path from +outside. Wired mode (`1e7d:2c92`) is mapped from the same protocol family but +was not exercised on this unit. ## Transport @@ -41,29 +43,45 @@ echo's later bytes are not established. Polling codes are the **opposite** of the Burst Core dict (`Roccat_Burst_Core_Mouse.js`) used on ROCCAT's older OEM mice: here -125 Hz → `0x03`, 1000 Hz → `0x00`, per the public plugin that drives this -generation. DPI is stored per-50 in the byte slots 8..17, five stages of two -axes interleaved. +125 Hz → `0x03`, 1000 Hz → `0x00`. The second session confirmed the mapping +from the other direction: switching the edited profile's polling to 125 Hz in +Swarm moved exactly the polling byte of that profile's reply page to `0x03`. + +The settings block is the **SignalRGB plugin's layout**: polling at offset 6, +one axis of five u16 little-endian DPI slots at offsets 9–18 (50/unit), lift +off at 19, `80` at 21, LED timeout at 22, debounce at 24. The mouse's own +reply pages line up with that block shifted right five bytes — polling at the +page's `b11`, the five u16 stages at `b14-23`, lift off at `b24` — and the +five `90 00 26` pages are the five onboard profiles, `b10` being the profile +index (see the 2026-10-16 capture). The earlier guess of two interleaved byte +lists at offsets 8..17 with lift off at 18 did not reproduce against the +mouse's stored tables and is superseded. ## What is verified on hardware - **The vendor-replayed settings write and its commit tail are accepted** and - echo the expected class frame (`10 50 03 …`). -- **A settings echo carried a full stage table once the mouse was awake**: - both slot lists identical, five entries `800 / 1200 / 1600 / 3200 / 5000`, - matching the unit's then-current Swarm configuration. On the same unit, - idle, the same query echoed an **empty body** (all twenty slots zero). The - decoder therefore treats an all-zero table as "no data". -- **The status probe answers every poll**: `90 0a 04 00 00 00 - `, where byte 5 moved `0x1e → 0x14` between sessions and bytes - 8–9 *increased* (2720 → 2884) — falsifying the "estimated minutes - remaining" reading of that word. Byte 5's meaning stays unresolved - (a battery percent, a signal strength and an elapsed counter all fit), so - the driver surfaces **no battery reading at all** rather than a guess; - `roccatIsStatusReply` still identifies the reply. + echo the expected class frame (`10 50 03 …`). Acceptance is all the echo + shows: no settings write sent by hand — this plugin layout or the earlier + interleaved guess — ever changed a stored stage table in a reply read back + afterwards. See "Write path" below. +- **The mouse's reply pages are the five onboard profiles**, decoded in the + 2026-10-16 capture: polling at `b11`, five u16 stage slots at `b14-23`, + lift-off at `b24`, LED timeout at `b27`, profile index at `b10`, and a + status byte at `b13` (00 empty / 01 valid / 02 being edited). The UI's own + DPI card and polling radios line up with those fields byte for byte. +- **The status probe answers every poll** (`90 0a 04 00 00 00 + ` when awake; `90 0a 03 00 ec fa 0d 00` with an all-zero body + while the mouse is asleep). Byte 5 tracked Swarm's own battery log across + sessions — `0x5a` read "battery level: 90", `0x64` read "battery level: + 100" — so it is the battery percent; the trailing u16 still only moves up in + a session and has no confirmed meaning. The driver still surfaces no + battery reading: the success runs are from Swarm's log rather than from this + capture, and the asleep body must not be read as 0 %. - **The lighting write is idempotent** on repeat (three identical writes returned identical answers), and leaves the mouse's LEDs stable. There is - no known read-back, so the codec ships lighting write-only. + no known read-back, so the codec ships lighting write-only — and the + 2026-10-16 reply pages also carry per-zone lighting frames (`90 01 0b …`), + which may or may not be a usable read-back and are not decoded. ## Deliberately not shown on the UI @@ -85,11 +103,26 @@ DPI is capped at 12,750 (255 × 50), the largest value the single slot byte can carry; the vendor software's higher numbers were never captured and do not fit this byte. +**Hardware status of the write path.** On the 2026-10-16 session, frames sent +by hand on interface 2 with stage 1 set to 500 were accepted and echoed, but +the reply pages afterwards still read the stored stage 1 at 400 in every +profile, and a listener on all four interfaces during Swarm edits saw the +vendor's read polls but no settings write on any of them. The vendor's device +DLL is a hidapi fork with no settings-write code — its `hid_write` is used +only by the calibration module. The honest reading is that a user-mode HID +write to this unit is acknowledged but not applied, and the vendor applies +settings over a channel a user-mode capture cannot see. The write path is +therefore best treated as **unverified on hardware**; the read-patch-write +machinery is the right shape, but nobody has watched a write land. + ## Open questions (any owner can settle these with a replug) -- Which slot list is sensor X and which is Y (both printed identical here). -- The status echo's battery-percent mapping (watch it drain while polling). +- Why a user-mode settings write is acknowledged but does not apply, and + which channel the vendor uses instead (the 2026-10-16 capture narrows it to + "not any HID interface a user-mode listener can open"). +- Which of the five u16 stage slots is sensor X and which is Y, if the unit + keeps separate axes at all — the reply pages print one list. +- `b13`'s exact states and `b26`'s values in the reply pages. - The wired generation's parity: interface index shifts by one per the community plugin; nothing was captured on the cable. -- The idle state's empty settings echo — does it also occur right after - replug while awake? +- Whether the per-zone lighting replies (`90 01 0b …`) are a usable read-back.