diff --git a/README.md b/README.md index 9f128e6..dab0e6a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ filters, the driver registry, and shared status types live under | SteelSeries | `@openmouse/keyboard-protocol/steelseries` | | Keychron (Hall-effect) | `@openmouse/keyboard-protocol/keychron` | | Arbiter Studio | `@openmouse/keyboard-protocol/arbiter` | +| ATK (Hall-effect and RS series) | `@openmouse/keyboard-protocol/atk` | | VIA (QMK/VIA firmware, any vendor) | `@openmouse/keyboard-protocol/via` | The VIA driver matches the raw-HID collection (`0xFF60:0x61`) on any board and @@ -117,6 +118,19 @@ effect and SuperTap, and writes travel, light effects, SuperTap and key bindings (including the PRO models' FN layer) with read-back. See [the Arbiter ledger](docs/arbiter-support.md). +### ATK + +ATK, EDGE, RS and Velota keyboards (vendor id `0x373B`, 107 catalogued product +ids) answer the VIA-framed raw-HID collection (`0xFF60:0x61`) with their own +custom commands, which is not VIA's command set. The codec and driver are +written from the OpenATK keyboard configurator, whose request frames the tests +compare byte for byte, and are not yet exercised on hardware through this +package. The driver reads identity, travel limits, dead zones, polling rate, +sleep, lighting and feature flags, and writes them with read-back, along with +per-key travel, rapid trigger and advanced keys. QMK-based boards that only +carry ATK branding (the Hex80 collaboration) are not catalogued and stay on the +generic VIA driver. See [the ATK ledger](docs/atk-support.md). + ### BABAO60 HE validation status Tested through the OpenMouse UI on a BABAO60 HE with protocol firmware 1.2.1.0: diff --git a/docs/atk-support.md b/docs/atk-support.md new file mode 100644 index 0000000..b3160f9 --- /dev/null +++ b/docs/atk-support.md @@ -0,0 +1,110 @@ +# ATK keyboard support + +`@openmouse/keyboard-protocol/atk` speaks the configuration protocol of ATK, +EDGE, RS and Velota keyboards (vendor id `0x373B`). It is written from the +OpenATK keyboard configurator, whose request frames are the reference for the +tests. It has not been exercised on hardware through this package. + +## Hardware verification + +| Board | Id | Verified | +| --- | --- | --- | +| Nothing yet | | | + +Everything below is implemented from the protocol and covered by tests against a +fake board and the OpenATK request frames only. Mark a board verified here, and +nowhere else, after each flow in the checklist has been run on it. + +## Transport + +- Collection: usage page `0xFF60`, usage `0x61`, the same raw-HID collection VIA + uses. The command set is not VIA's. Boards are matched by catalogued product + id, never by collection alone, so a VIA keyboard from another vendor is not + claimed. The registry places this driver ahead of the generic VIA driver. +- Reports: report id 0, 32 bytes, zero padded. Byte 0 is the command, the + payload starts at byte 1. +- Replies echo the command. For `getKeyboardValue` and `setKeyboardValue` + (`0x02`, `0x03`) they also echo byte 1, and for the custom id `0x96` byte 2, + the channel. There is no transaction id, so the driver serializes requests + and retries an unanswered request up to three times, 1.2 s apart, before + failing. + +## Commands + +| Command | Use | +| --- | --- | +| `0x01` | protocol version, big-endian at reply bytes 1 and 2 | +| `0x02` / `0x03` | get / set a keyboard value. With `0x96` at byte 1, byte 2 is a custom channel | +| `0x07` / `0x08` | set / get the back-lamp lighting block (`0x41`) | +| `0x12` | read keymap bytes: offset (2 bytes), length (at most 28) | +| `0xFE` | tap-dance entry write (used for MT and TGL) | + +Firmware is `0x02 0x96` with no channel; reply bytes 5 and 6 are the version in +hex digits ("2.10" is 2.1). Firmware 2.1 and later moves the logo and side +lights onto the decoration channel. + +## Custom channels + +| Channel | Meaning | Notes | +| --- | --- | --- | +| `0x03` | per-key actuation | `[row, col, hi, lo]`, millimetres times 1000 | +| `0x07` | per-key rapid trigger | `[row, col, on, release hi/lo, press hi/lo]` | +| `0x0B` | dead zone | top and bottom, millimetres times 1000 | +| `0x0D` | tap-dance entry | sent under `0xFE`, see below | +| `0x0F` | dynamic keystroke (DKS) | up to four bindings plus top and bottom travel | +| `0x11` | feature block | RGB area, WASD swap, Mac mode, Win lock, NKRO (0 to 2), key mode (read only) | +| `0x1D` / `0x1E` / `0x25` | flags | bottom optimization, game mode, AI match | +| `0x20` | advanced keys | RS, SOCD, OKS and AKS slots, 20 of them | +| `0x21` / `0x27` | logo / side light | firmware before 2.1 | +| `0x24` | travel limits | six values: travel max, min and step, rapid-trigger max, min and step | +| `0x26` | light-off timer | minutes, 0 means never | +| `0x46` | polling rate | 0 to 3 for 8 kHz, 4 kHz, 2 kHz, 1 kHz. A restart flag (`0x4E`) is written first | +| `0x4B` | single-key lighting | `[key, r, g, b]` | +| `0x53` | zone lighting | logo 240, side 241, front 242, button 243, zone id last on writes | + +Advanced keys are `[0x96, 0x20, 0, 0, sub, index, mode, index, point hi/lo, 0, +rt, key1(2), key2(2), layer, trigger delay(2), key delay(2)]`. Sub 1 reads, sub 3 +writes. Modes: 0 none, 1 RS, 2 to 5 SOCD (last, key 1, key 2, neutral), 6 OKS, +7 to 9 AKS. + +## Implemented + +Identity (protocol, firmware, product name and matrix), travel limits, dead +zone, feature flags, polling rate, sleep, back-lamp and zone lighting, per-key +lighting, per-key travel and rapid trigger, advanced keys (RS, SOCD, OKS, AKS), +MT, TGL and DKS writes, and keymap read. + +Writes are read back where the protocol has a matching read: polling rate, +sleep, dead zone, feature block, flags, lighting, key travel, rapid trigger, +key light and advanced keys. MT, TGL and DKS have no read command in the +configurator, so those writes are only acknowledged, not confirmed. + +## Not exposed + +- Keymap writes, macros, profiles and firmware update. The configurator this was + written from does not write the keymap. +- Calibration and live key-travel monitoring. +- LCD, wireless pairing and side-light position modes (`0x51`). +- Boards with no catalogued matrix (RS8 Neo, the Halo and Nothing68 family) get + every setting but not the keymap read. + +## Catalog + +107 product ids in `src/atk/products.ts`, taken from the OpenATK registry. The +four `ATK x QK Hex80` entries are left out: they are QMK-based collaboration +boards that belong on the generic VIA driver, not here. The 2.4G entries are +reported as wireless from their name; that is inferred, not read from the board. + +## Hardware checklist + +Run each on a connected board and note the result under the table above. Stop +at the first unexpected value. + +1. Connect, read status: name, firmware, rate, sleep and lighting match the + vendor app. +2. Change polling rate, replug, confirm it stuck. +3. Change sleep timer and the game-mode flag, confirm in the vendor app. +4. Change one key's actuation and rapid trigger, read them back, test the key. +5. Change back-lamp effect, colour and brightness. +6. Set an RS or SOCD pair and clear it. +7. Read the keymap and compare with the vendor app. diff --git a/package.json b/package.json index 9fdd269..c5156b2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "docs/steelseries-support.md", "docs/keychron-support.md", "docs/arbiter-support.md", + "docs/atk-support.md", "docs/via-support.md", "README.md", "CONTRIBUTING.md" @@ -56,6 +57,10 @@ "types": "./dist/keychron/index.d.ts", "import": "./dist/keychron/index.js" }, + "./atk": { + "types": "./dist/atk/index.d.ts", + "import": "./dist/atk/index.js" + }, "./arbiter": { "types": "./dist/arbiter/index.d.ts", "import": "./dist/arbiter/index.js" diff --git a/src/atk/index.ts b/src/atk/index.ts new file mode 100644 index 0000000..cb8e35f --- /dev/null +++ b/src/atk/index.ts @@ -0,0 +1,490 @@ +export const ATK_VENDOR_ID = 0x373b; +export const ATK_CONFIG_USAGE_PAGE = 0xff60; +export const ATK_CONFIG_USAGE = 0x61; +export const ATK_REPORT_ID = 0; +export const ATK_FRAME_LENGTH = 32; +export const ATK_PAYLOAD_OFFSET = 1; +export const ATK_CUSTOM_ID = 0x96; +export const ATK_KEYMAP_CHUNK = 28; +export const ATK_ADVANCED_SLOTS = 20; +export const ATK_TAP_DANCE_PREFIX = 0xfe; + +export const ATK_COMMAND = { + getProtocolVersion: 0x01, + getKeyboardValue: 0x02, + setKeyboardValue: 0x03, + lightingSetValue: 0x07, + lightingGetValue: 0x08, + keymapGetBuffer: 0x12, +} as const; + +export const ATK_CHANNEL = { + oneTogTh: 0x03, + oneRt: 0x07, + deadBand: 0x0b, + dks: 0x0f, + entry: 0x0d, + feature: 0x11, + bottomOptimize: 0x1d, + gameMode: 0x1e, + advancedKey: 0x20, + logoLight: 0x21, + travel: 0x24, + aiMatch: 0x25, + lightOff: 0x26, + sideLight: 0x27, + lightInfo: 0x41, + reportRate: 0x46, + keyLight: 0x4b, + restart: 0x4e, + lightPositionMode: 0x51, + decorationLight: 0x53, +} as const; + +export const ATK_LIGHT_ZONE = { logo: 240, side: 241, front: 242, button: 243 } as const; +export type AtkLightZone = (typeof ATK_LIGHT_ZONE)[keyof typeof ATK_LIGHT_ZONE]; + +export const ATK_ADVANCED_MODE = { + none: 0, + rs: 1, + socd: 2, + socdKey1: 3, + socdKey2: 4, + socdNeutral: 5, + oks: 6, + aksKey1: 7, + aksKey2: 8, + aksNeutral: 9, +} as const; + +export const ATK_REPORT_RATES: ReadonlyArray = [ + [8000, 0], + [4000, 1], + [2000, 2], + [1000, 3], +]; + +export const ATK_SLEEP_NEVER = 65535; +export const ATK_NKRO_MODES = ["Force 6-key", "Smart detect", "Force full-key"] as const; + +export type AtkRgb = readonly [number, number, number]; + +function byte(value: number, name: string): number { + if (!Number.isInteger(value) || value < 0 || value > 0xff) throw new RangeError(`${name} must be a byte, got ${value}`); + return value; +} + +function milli(mm: number, name: string): number { + const value = Math.round(mm * 1000); + if (!Number.isFinite(mm) || value < 0 || value > 0xffff) throw new RangeError(`${name} must be 0 to 65.535 mm, got ${mm}`); + return value; +} + +function u16(frame: Uint8Array, index: number): number { + return ((frame[index] ?? 0) << 8) | (frame[index + 1] ?? 0); +} + +export function atkFrame(command: number, payload: readonly number[] = []): Uint8Array { + if (payload.length > ATK_FRAME_LENGTH - ATK_PAYLOAD_OFFSET) throw new RangeError("ATK payload does not fit in one 32-byte report"); + const frame = new Uint8Array(ATK_FRAME_LENGTH); + frame[0] = byte(command, "command"); + payload.forEach((value, index) => { frame[ATK_PAYLOAD_OFFSET + index] = value & 0xff; }); + return frame; +} + +export function atkCustomGet(channel: number, extra: readonly number[] = []): Uint8Array { + return atkFrame(ATK_COMMAND.getKeyboardValue, [ATK_CUSTOM_ID, channel, ...extra]); +} + +export function atkCustomSet(channel: number, data: readonly number[]): Uint8Array { + return atkFrame(ATK_COMMAND.setKeyboardValue, [ATK_CUSTOM_ID, channel, ...data]); +} + +export function atkReplyMatches(sent: Uint8Array, got: Uint8Array): boolean { + if (sent[0] !== got[0]) return false; + if (sent[0] !== ATK_COMMAND.getKeyboardValue && sent[0] !== ATK_COMMAND.setKeyboardValue) return true; + if (sent[1] !== got[1]) return false; + if (sent[1] !== ATK_CUSTOM_ID) return true; + return sent[2] === got[2]; +} + +export function atkBuildGetProtocolVersion(): Uint8Array { + return atkFrame(ATK_COMMAND.getProtocolVersion); +} + +export function atkDecodeProtocolVersion(reply: Uint8Array): number { + return u16(reply, 1); +} + +export function atkBuildGetFirmware(): Uint8Array { + return atkFrame(ATK_COMMAND.getKeyboardValue, [ATK_CUSTOM_ID]); +} + +export interface AtkFirmwareVersion { + text: string; + value: number; +} + +export function atkDecodeFirmware(reply: Uint8Array): AtkFirmwareVersion { + const text = `${reply[5]!.toString(16)}.${reply[6]!.toString(16).padStart(2, "0")}`; + return { text, value: Number(text) }; +} + +export function atkIsFirmwareV2(firmware: AtkFirmwareVersion | null): boolean { + return firmware !== null && firmware.value >= 2.1; +} + +export interface AtkTravelLimits { + travelMax: number; + travelMin: number; + travelStep: number; + rtMax: number; + rtMin: number; + rtStep: number; +} + +export function atkBuildGetTravelLimits(): Uint8Array { + return atkCustomGet(ATK_CHANNEL.travel); +} + +export function atkDecodeTravelLimits(reply: Uint8Array): AtkTravelLimits { + const at = (offset: number): number => u16(reply, ATK_PAYLOAD_OFFSET + offset) / 1000; + return { travelMax: at(2), travelMin: at(4), travelStep: at(6), rtMax: at(8), rtMin: at(10), rtStep: at(12) }; +} + +export interface AtkDeadZone { + top: number; + bottom: number; +} + +export function atkBuildGetDeadZone(): Uint8Array { + return atkCustomGet(ATK_CHANNEL.deadBand); +} + +export function atkBuildSetDeadZone(zone: AtkDeadZone): Uint8Array { + const top = milli(zone.top, "top"); + const bottom = milli(zone.bottom, "bottom"); + return atkCustomSet(ATK_CHANNEL.deadBand, [top >> 8, top & 0xff, bottom >> 8, bottom & 0xff]); +} + +export function atkDecodeDeadZone(reply: Uint8Array): AtkDeadZone { + return { top: u16(reply, ATK_PAYLOAD_OFFSET + 2) / 1000, bottom: u16(reply, ATK_PAYLOAD_OFFSET + 4) / 1000 }; +} + +export interface AtkFeature { + rgbArea: number; + exchangeWasd: number; + macMode: number; + winLock: number; + nkro: number; + keyMode: number; +} + +export function atkBuildGetFeature(): Uint8Array { + return atkCustomGet(ATK_CHANNEL.feature); +} + +export function atkDecodeFeature(reply: Uint8Array): AtkFeature { + const at = (offset: number): number => reply[ATK_PAYLOAD_OFFSET + offset] ?? 0; + return { rgbArea: at(2), exchangeWasd: at(3), macMode: at(4), winLock: at(5), nkro: at(6), keyMode: at(7) }; +} + +export function atkBuildSetFeature(feature: Omit): Uint8Array { + if (feature.nkro < 0 || feature.nkro >= ATK_NKRO_MODES.length) throw new RangeError("NKRO mode must be 0, 1 or 2"); + return atkCustomSet(ATK_CHANNEL.feature, [ + byte(feature.rgbArea, "rgbArea"), feature.exchangeWasd ? 1 : 0, feature.macMode ? 1 : 0, + feature.winLock ? 1 : 0, byte(feature.nkro, "nkro"), + ]); +} + +export const ATK_FLAG_CHANNEL = { + gameMode: ATK_CHANNEL.gameMode, + aiMatch: ATK_CHANNEL.aiMatch, + bottomOptimize: ATK_CHANNEL.bottomOptimize, +} as const; +export type AtkFlag = keyof typeof ATK_FLAG_CHANNEL; + +export function atkBuildGetFlag(flag: AtkFlag): Uint8Array { + return atkCustomGet(ATK_FLAG_CHANNEL[flag]); +} + +export function atkBuildSetFlag(flag: AtkFlag, enabled: boolean): Uint8Array { + return atkCustomSet(ATK_FLAG_CHANNEL[flag], [enabled ? 1 : 0]); +} + +export function atkDecodeFlag(reply: Uint8Array): boolean { + return (reply[ATK_PAYLOAD_OFFSET + 2] ?? 0) !== 0; +} + +export function atkBuildGetSleep(): Uint8Array { + return atkCustomGet(ATK_CHANNEL.lightOff); +} + +export function atkDecodeSleep(reply: Uint8Array): number { + const minutes = reply[ATK_PAYLOAD_OFFSET + 2] ?? 0; + return minutes === 0 ? ATK_SLEEP_NEVER : minutes * 60; +} + +export function atkBuildSetSleep(seconds: number): Uint8Array { + if (seconds === ATK_SLEEP_NEVER) return atkCustomSet(ATK_CHANNEL.lightOff, [0]); + const minutes = Math.round(seconds / 60); + if (minutes < 1 || minutes > 0xff) throw new RangeError("Sleep must be 1 to 255 minutes, or never"); + return atkCustomSet(ATK_CHANNEL.lightOff, [minutes]); +} + +export function atkBuildGetReportRate(): Uint8Array { + return atkCustomGet(ATK_CHANNEL.reportRate); +} + +export function atkDecodeReportRate(reply: Uint8Array): number | null { + const code = reply[ATK_PAYLOAD_OFFSET + 2]; + return ATK_REPORT_RATES.find(([, value]) => value === code)?.[0] ?? null; +} + +export function atkBuildSetReportRate(hz: number): Uint8Array[] { + const entry = ATK_REPORT_RATES.find(([rate]) => rate === hz); + if (!entry) throw new RangeError(`Unsupported report rate: ${hz}`); + return [atkCustomSet(ATK_CHANNEL.restart, [1]), atkCustomSet(ATK_CHANNEL.reportRate, [entry[1]])]; +} + +export interface AtkLightConfig { + effect: number; + speed: number; + color: AtkRgb; + brightness: number; + colorful: boolean; +} + +export function atkBuildGetLightConfig(): Uint8Array { + return atkFrame(ATK_COMMAND.lightingGetValue, [ATK_CHANNEL.lightInfo]); +} + +export function atkDecodeLightConfig(reply: Uint8Array): AtkLightConfig { + const at = (offset: number): number => reply[ATK_PAYLOAD_OFFSET + offset] ?? 0; + return { effect: at(1), speed: at(3), color: [at(4), at(5), at(6)], brightness: at(7), colorful: at(8) !== 0 }; +} + +export function atkBuildSetLightConfig(config: AtkLightConfig): Uint8Array { + return atkFrame(ATK_COMMAND.lightingSetValue, [ + ATK_CHANNEL.lightInfo, byte(config.effect, "effect"), 0, byte(config.speed, "speed"), + byte(config.color[0], "red"), byte(config.color[1], "green"), byte(config.color[2], "blue"), + byte(config.brightness, "brightness"), config.colorful ? 1 : 0, + ]); +} + +export interface AtkZoneLight { + effect: number; + colorful: boolean; + brightness: number; + speed: number; + color: AtkRgb; +} + +export function atkZoneChannel(zone: AtkLightZone, v2: boolean): number { + if (!v2 && zone <= ATK_LIGHT_ZONE.side) return zone === ATK_LIGHT_ZONE.logo ? ATK_CHANNEL.logoLight : ATK_CHANNEL.sideLight; + return ATK_CHANNEL.decorationLight; +} + +export function atkBuildGetZoneLight(zone: AtkLightZone, v2: boolean): Uint8Array { + const channel = atkZoneChannel(zone, v2); + return atkCustomGet(channel, channel === ATK_CHANNEL.decorationLight ? [zone] : []); +} + +export function atkDecodeZoneLight(reply: Uint8Array): AtkZoneLight { + const at = (offset: number): number => reply[ATK_PAYLOAD_OFFSET + offset] ?? 0; + return { effect: at(2), colorful: at(3) !== 0, brightness: at(4), speed: at(5), color: [at(6), at(7), at(8)] }; +} + +export function atkBuildSetZoneLight(zone: AtkLightZone, v2: boolean, light: AtkZoneLight): Uint8Array { + const channel = atkZoneChannel(zone, v2); + const data = [ + byte(light.effect, "effect"), light.colorful ? 1 : 0, byte(light.brightness, "brightness"), byte(light.speed, "speed"), + byte(light.color[0], "red"), byte(light.color[1], "green"), byte(light.color[2], "blue"), + ]; + return atkCustomSet(channel, channel === ATK_CHANNEL.decorationLight ? [...data, zone] : data); +} + +export function atkBuildGetKeyLight(key: number): Uint8Array { + return atkCustomGet(ATK_CHANNEL.keyLight, [byte(key, "key")]); +} + +export function atkDecodeKeyLight(reply: Uint8Array): { key: number; color: AtkRgb } { + const at = (offset: number): number => reply[ATK_PAYLOAD_OFFSET + offset] ?? 0; + return { key: at(2), color: [at(3), at(4), at(5)] }; +} + +export function atkBuildSetKeyLight(key: number, color: AtkRgb): Uint8Array { + return atkCustomSet(ATK_CHANNEL.keyLight, [byte(key, "key"), byte(color[0], "red"), byte(color[1], "green"), byte(color[2], "blue")]); +} + +export function atkBuildGetKeyTravel(row: number, col: number): Uint8Array { + return atkCustomGet(ATK_CHANNEL.oneTogTh, [byte(row, "row"), byte(col, "col")]); +} + +export function atkDecodeKeyTravel(reply: Uint8Array): number { + return u16(reply, ATK_PAYLOAD_OFFSET + 4) / 1000; +} + +export function atkBuildSetKeyTravel(row: number, col: number, mm: number): Uint8Array { + const value = milli(mm, "travel"); + return atkCustomSet(ATK_CHANNEL.oneTogTh, [byte(row, "row"), byte(col, "col"), value >> 8, value & 0xff]); +} + +export interface AtkRapidTrigger { + enabled: boolean; + press: number; + release: number; +} + +export function atkBuildGetKeyRapidTrigger(row: number, col: number): Uint8Array { + return atkCustomGet(ATK_CHANNEL.oneRt, [byte(row, "row"), byte(col, "col")]); +} + +export function atkDecodeKeyRapidTrigger(reply: Uint8Array): AtkRapidTrigger { + return { + enabled: (reply[ATK_PAYLOAD_OFFSET + 4] ?? 0) !== 0, + release: u16(reply, ATK_PAYLOAD_OFFSET + 5) / 1000, + press: u16(reply, ATK_PAYLOAD_OFFSET + 7) / 1000, + }; +} + +export function atkBuildSetKeyRapidTrigger(row: number, col: number, rapid: AtkRapidTrigger): Uint8Array { + const press = milli(rapid.press, "press"); + const release = milli(rapid.release, "release"); + return atkCustomSet(ATK_CHANNEL.oneRt, [ + byte(row, "row"), byte(col, "col"), rapid.enabled ? 1 : 0, release >> 8, release & 0xff, press >> 8, press & 0xff, + ]); +} + +export interface AtkAdvancedKey { + index: number; + mode: number; + point: number; + rt: boolean; + key1: readonly [number, number]; + key2: readonly [number, number]; + layer: number; +} + +export type AtkAdvancedKeyWrite = Partial> & { + triggerDelay?: number; + keyDelay?: number; +}; + +const ADVANCED_GET = 1; +const ADVANCED_SET = 3; + +function advancedPayload(sub: number, index: number, write: AtkAdvancedKeyWrite = {}): number[] { + const payload = new Array(23).fill(0); + payload[0] = ATK_CUSTOM_ID; + payload[1] = ATK_CHANNEL.advancedKey; + payload[4] = sub; + payload[5] = byte(index, "index"); + payload[7] = index; + if (write.mode !== undefined) payload[6] = byte(write.mode, "mode"); + if (write.point !== undefined) { + const point = milli(write.point, "point"); + payload[8] = point >> 8; + payload[9] = point & 0xff; + } + if (write.rt !== undefined) payload[11] = write.rt ? 1 : 0; + if (write.key1) { payload[12] = byte(write.key1[0], "key1"); payload[13] = byte(write.key1[1], "key1"); } + if (write.key2) { payload[14] = byte(write.key2[0], "key2"); payload[15] = byte(write.key2[1], "key2"); } + if (write.layer !== undefined) payload[16] = byte(write.layer, "layer"); + if (write.triggerDelay !== undefined) { + payload[17] = (write.triggerDelay >> 8) & 0xff; + payload[18] = write.triggerDelay & 0xff; + } + if (write.keyDelay !== undefined) { + payload[19] = (write.keyDelay >> 8) & 0xff; + payload[20] = write.keyDelay & 0xff; + } + return payload; +} + +export function atkBuildGetAdvancedKey(index: number): Uint8Array { + return atkFrame(ATK_COMMAND.getKeyboardValue, advancedPayload(ADVANCED_GET, index)); +} + +export function atkBuildSetAdvancedKey(index: number, write: AtkAdvancedKeyWrite): Uint8Array { + return atkFrame(ATK_COMMAND.setKeyboardValue, advancedPayload(ADVANCED_SET, index, write)); +} + +export function atkBuildClearAdvancedKey(index: number): Uint8Array { + return atkBuildSetAdvancedKey(index, { mode: ATK_ADVANCED_MODE.none }); +} + +export function atkDecodeAdvancedKey(index: number, reply: Uint8Array): AtkAdvancedKey { + const at = (offset: number): number => reply[ATK_PAYLOAD_OFFSET + offset] ?? 0; + return { + index, + mode: at(6), + point: u16(reply, ATK_PAYLOAD_OFFSET + 8) / 1000, + rt: at(11) !== 0, + key1: [at(12), at(13)], + key2: [at(14), at(15)], + layer: at(16), + }; +} + +export function atkBuildTapDance(index: number, tap: number, hold: number, timerMs: number): Uint8Array { + if (timerMs < 0 || timerMs > 0xffff) throw new RangeError("Tap-dance timer must fit 16 bits"); + return atkFrame(ATK_TAP_DANCE_PREFIX, [ + ATK_CHANNEL.entry, 2, byte(index, "index"), 0, byte(tap, "tap"), 0, byte(hold, "hold"), + (timerMs >> 8) & 0xff, timerMs & 0xff, + ]); +} + +export function atkBuildMt(index: number, tap: number, hold: number, delayMs: number): Uint8Array { + return atkBuildTapDance(index, tap, hold, delayMs); +} + +export function atkBuildTgl(index: number, key: number, delayMs: number): Uint8Array { + return atkBuildTapDance(index + ATK_ADVANCED_SLOTS, key, 0, delayMs); +} + +export function atkBuildSetDks(index: number, codes: readonly number[], top: number, bottom: number): Uint8Array { + if (codes.length > 4) throw new RangeError("DKS takes at most 4 bindings"); + const payload = new Array(26).fill(0); + payload[0] = ATK_CUSTOM_ID; + payload[1] = ATK_CHANNEL.dks; + payload[4] = ADVANCED_SET; + payload[5] = byte(index, "index"); + codes.forEach((code, slot) => { + const offset = 6 + 4 * slot; + payload[offset] = code ? 0x0f : 0; + payload[offset + 3] = byte(code, "code"); + }); + const t = milli(top, "top"); + const b = milli(bottom, "bottom"); + payload[22] = t >> 8; + payload[23] = t & 0xff; + payload[24] = b >> 8; + payload[25] = b & 0xff; + return atkFrame(ATK_COMMAND.setKeyboardValue, payload); +} + +export function atkBuildGetKeymapChunk(offset: number, length: number): Uint8Array { + if (length < 1 || length > ATK_KEYMAP_CHUNK) throw new RangeError(`Keymap chunk must be 1 to ${ATK_KEYMAP_CHUNK} bytes`); + return atkFrame(ATK_COMMAND.keymapGetBuffer, [offset >> 8, offset & 0xff, length]); +} + +export function atkKeymapChunks(rows: number, cols: number): Array<{ offset: number; length: number }> { + const total = rows * cols * 2; + const chunks: Array<{ offset: number; length: number }> = []; + for (let offset = 0; offset < total; offset += ATK_KEYMAP_CHUNK) { + chunks.push({ offset, length: Math.min(ATK_KEYMAP_CHUNK, total - offset) }); + } + return chunks; +} + +export function atkDecodeKeymapChunk(reply: Uint8Array, length: number): number[] { + return Array.from(reply.subarray(4, 4 + length)); +} + +export function atkDecodeKeymap(bytes: readonly number[], rows: number, cols: number): number[] { + const codes: number[] = []; + for (let i = 0; i < rows * cols; i++) codes.push(((bytes[i * 2] ?? 0) << 8) | (bytes[i * 2 + 1] ?? 0)); + return codes; +} +export * from "./products.ts"; diff --git a/src/atk/products.ts b/src/atk/products.ts new file mode 100644 index 0000000..ab1f93c --- /dev/null +++ b/src/atk/products.ts @@ -0,0 +1,125 @@ +export interface AtkKeyboardProduct { + productId: number; + name: string; + rows: number | null; + cols: number | null; + group: string | null; +} + +export const ATK_KEYBOARDS: readonly AtkKeyboardProduct[] = [ + { productId: 0x1044, name: "ATK RS7", rows: 6, cols: 16, group: null }, + { productId: 0x1048, name: "ATK68 V2 Pro", rows: 5, cols: 15, group: null }, + { productId: 0x1082, name: "EDGE 60HE ULTIMATE", rows: 5, cols: 14, group: "EDGE60-48E" }, + { productId: 0x108e, name: "ATK EDGE75", rows: 6, cols: 15, group: null }, + { productId: 0x1090, name: "ATK EDGE75", rows: 6, cols: 15, group: null }, + { productId: 0x109c, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-48E" }, + { productId: 0x109d, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-48E" }, + { productId: 0x109e, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-48E" }, + { productId: 0x10af, name: "EDGE 60HE ULTIMATE", rows: 5, cols: 14, group: "EDGE60-48E" }, + { productId: 0x10b0, name: "EDGE 60HE ULTIMATE", rows: 5, cols: 14, group: "EDGE60-48E" }, + { productId: 0x1112, name: "ATK68 V3", rows: 5, cols: 15, group: "ATK68V3-48E" }, + { productId: 0x1114, name: "EDGE 60HE ULTIMATE", rows: 5, cols: 14, group: "EDGE60-48E" }, + { productId: 0x111e, name: "ATK A87 PRO", rows: 8, cols: 16, group: null }, + { productId: 0x111f, name: "ATK A87 PRO", rows: 8, cols: 16, group: null }, + { productId: 0x113b, name: "EDGE 63HE ULTIMATE", rows: 5, cols: 14, group: "EDGE63-48E" }, + { productId: 0x113c, name: "EDGE 63HE ULTIMATE", rows: 5, cols: 14, group: "EDGE63-48E" }, + { productId: 0x113d, name: "EDGE 63HE ULTIMATE", rows: 5, cols: 14, group: "EDGE63-48E" }, + { productId: 0x114d, name: "ATK A98", rows: 8, cols: 16, group: null }, + { productId: 0x114e, name: "ATK A98", rows: 8, cols: 16, group: null }, + { productId: 0x1151, name: "ATK A98", rows: 8, cols: 16, group: null }, + { productId: 0x1152, name: "ATK A98", rows: 8, cols: 16, group: null }, + { productId: 0x115f, name: "EDGE 60HE ULTIMATE", rows: 5, cols: 14, group: "EDGE60-4015" }, + { productId: 0x1160, name: "EDGE 60HE ULTIMATE", rows: 5, cols: 14, group: "EDGE60-4015" }, + { productId: 0x1161, name: "EDGE 60HE ULTIMATE", rows: 5, cols: 14, group: "EDGE60-4015" }, + { productId: 0x1162, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-4015" }, + { productId: 0x1163, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-Ultra-4015" }, + { productId: 0x1164, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-Ultra-4015" }, + { productId: 0x1168, name: "EDGE 63HE ULTIMATE", rows: 5, cols: 14, group: "EDGE63-4015" }, + { productId: 0x1169, name: "EDGE 63HE ULTIMATE", rows: 5, cols: 14, group: "EDGE63-4015" }, + { productId: 0x116a, name: "EDGE 63HE ULTIMATE", rows: 5, cols: 14, group: "EDGE63-4015" }, + { productId: 0x116b, name: "ATK68 V3", rows: 5, cols: 15, group: "ATK68V3-4015" }, + { productId: 0x1174, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Ultra-4015" }, + { productId: 0x1197, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Ultra-4015" }, + { productId: 0x1198, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Ultra-4015" }, + { productId: 0x119a, name: "ATK68 RX", rows: 5, cols: 15, group: "ATK68RX-4015" }, + { productId: 0x11a8, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Extreme-4015" }, + { productId: 0x11ad, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-4015" }, + { productId: 0x11ae, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Ultra-4015" }, + { productId: 0x11af, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Ultra-4015" }, + { productId: 0x11b0, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Ultra-4015" }, + { productId: 0x11b8, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-Ultra-4015" }, + { productId: 0x11b9, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-FLEET18-4015" }, + { productId: 0x11ba, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Ultra-4015" }, + { productId: 0x11c0, name: "ATK68 RX", rows: 5, cols: 15, group: "ATK68RX-4015" }, + { productId: 0x11c1, name: "ATK68 RX", rows: 5, cols: 15, group: "ATK68RX-4015" }, + { productId: 0x11c5, name: "ATK60 RX", rows: 5, cols: 14, group: null }, + { productId: 0x11c6, name: "ATK60 RX", rows: 5, cols: 14, group: "ATK60RX-Ultra" }, + { productId: 0x11c7, name: "ATK60 RX", rows: 5, cols: 14, group: "ATK60RX-Limited" }, + { productId: 0x11d0, name: "ATK RS6 Air", rows: 5, cols: 15, group: null }, + { productId: 0x11d6, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-Ultra-4015" }, + { productId: 0x11da, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Ultra-4015" }, + { productId: 0x11df, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-4015" }, + { productId: 0x11e0, name: "ATK RS7 Turbo", rows: 6, cols: 15, group: "RS7-Turbo-4015" }, + { productId: 0x11e1, name: "ATK RS7 Turbo", rows: 6, cols: 15, group: "RS7-Turbo-4015" }, + { productId: 0x11e6, name: "ATK68 V3", rows: 5, cols: 15, group: "ATK68V3-4015" }, + { productId: 0x11e8, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-48E" }, + { productId: 0x11e9, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-Ultra-48E" }, + { productId: 0x11ea, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-4015" }, + { productId: 0x11eb, name: "ATK RS6 Ultra", rows: 5, cols: 15, group: "RS6-Ultra-4015" }, + { productId: 0x11fc, name: "ATK60 RX", rows: 5, cols: 14, group: null }, + { productId: 0x1206, name: "ATK EDGE75", rows: 6, cols: 15, group: "EDGE75-2" }, + { productId: 0x1209, name: "ATK RS6 Air", rows: 5, cols: 15, group: null }, + { productId: 0x120f, name: "ATK RS6 +", rows: 5, cols: 15, group: null }, + { productId: 0x1210, name: "ATK RS6 +", rows: 5, cols: 15, group: "RS6-Ultra-Plus-4015" }, + { productId: 0x1220, name: "ATK RS7 Air", rows: 6, cols: 15, group: null }, + { productId: 0x1221, name: "ATK RS7 Air", rows: 6, cols: 15, group: null }, + { productId: 0x122b, name: "ATK RS7 Air", rows: 6, cols: 15, group: null }, + { productId: 0x122c, name: "ATK RS8 Air", rows: 6, cols: 17, group: null }, + { productId: 0x122e, name: "ATK RS8 TURBO", rows: 6, cols: 17, group: null }, + { productId: 0x122f, name: "ATK RS8 TURBO", rows: 6, cols: 17, group: null }, + { productId: 0x1231, name: "ATK A87 PRO", rows: 8, cols: 16, group: null }, + { productId: 0x1241, name: "ATK A87 Ultra", rows: 6, cols: 16, group: null }, + { productId: 0x1242, name: "ATK A87 Ultra 2.4G", rows: 6, cols: 16, group: null }, + { productId: 0x1243, name: "ATK A98 Ultra", rows: 6, cols: 18, group: null }, + { productId: 0x1244, name: "ATK A98 Ultra 2.4G", rows: 6, cols: 18, group: null }, + { productId: 0x1247, name: "ATK V75X Ultra", rows: 6, cols: 15, group: null }, + { productId: 0x124b, name: "ATK V75X Ultra 2.4G", rows: 6, cols: 15, group: null }, + { productId: 0x1254, name: "ATK RS8 Neo", rows: null, cols: null, group: null }, + { productId: 0x1255, name: "ATK RS8 Neo 2.4G", rows: null, cols: null, group: null }, + { productId: 0x1257, name: "ATK RS7 Air", rows: 6, cols: 15, group: "RS7-Air-2" }, + { productId: 0x1258, name: "ATK RS7 Air", rows: 6, cols: 15, group: "RS7-Air-2" }, + { productId: 0x1259, name: "ATK RS6 Cube", rows: 5, cols: 15, group: "RS6-Cube" }, + { productId: 0x125b, name: "ATK RS6 Air", rows: 5, cols: 15, group: "RS6-Air-2" }, + { productId: 0x125c, name: "ATK RS6 Air", rows: 5, cols: 15, group: "RS6-Air-2" }, + { productId: 0x125d, name: "VELOTA FUZZY 60 V2", rows: 5, cols: 14, group: null }, + { productId: 0x1267, name: "VELOTA FUZZY 60 V2", rows: 5, cols: 14, group: null }, + { productId: 0x126a, name: "RS 63 Air", rows: 5, cols: 14, group: null }, + { productId: 0x126b, name: "RS 63 Air", rows: 5, cols: 14, group: null }, + { productId: 0x1294, name: "ATK RS6 Air", rows: 5, cols: 15, group: "RS6-Air-Cyber" }, + { productId: 0x1295, name: "ATK RS7 Air", rows: 6, cols: 15, group: "RS7-Air-Cyber" }, + { productId: 0x12a0, name: "ATK RS8 Air", rows: 6, cols: 17, group: null }, + { productId: 0x12a1, name: "ATK RS8 Air", rows: 6, cols: 17, group: null }, + { productId: 0x12a6, name: "VELOTA FUZZY 63", rows: 5, cols: 15, group: null }, + { productId: 0x12ad, name: "ATK Keyboard", rows: null, cols: null, group: null }, + { productId: 0x12ae, name: "ATK Keyboard", rows: null, cols: null, group: null }, + { productId: 0x12c0, name: "ATK Keyboard", rows: null, cols: null, group: null }, + { productId: 0x12c1, name: "ATK RS7 Air", rows: 6, cols: 15, group: "RS7-Air-ISO" }, + { productId: 0x12ca, name: "ATK RS8 Air", rows: 6, cols: 17, group: null }, + { productId: 0x12cb, name: "ATK RS8 Air", rows: 6, cols: 17, group: null }, + { productId: 0x12eb, name: "EDGE 60HE ULTIMATE", rows: 5, cols: 14, group: "EDGE60-48E" }, + { productId: 0x12f0, name: "ATK RS6 Cube", rows: 5, cols: 15, group: "RS6-Cube" }, + { productId: 0x12fc, name: "ATK Keyboard", rows: null, cols: null, group: null }, + { productId: 0x12fd, name: "ATK Keyboard", rows: null, cols: null, group: null }, + { productId: 0x12fe, name: "ATK RS6 +", rows: 5, cols: 15, group: null }, + { productId: 0x1335, name: "ATK RS7 Air", rows: 6, cols: 15, group: "RS7-Air-4" }, + { productId: 0x1336, name: "ATK RS7 V2 Ultra", rows: 6, cols: 15, group: "RS7V2-Ultra-4015" }, + { productId: 0x1339, name: "ATK RS7 Air", rows: 6, cols: 15, group: null }, +]; + +export const ATK_KEYBOARD_PRODUCT_IDS: readonly number[] = ATK_KEYBOARDS.map((product) => product.productId); + +const BY_PRODUCT_ID = new Map(ATK_KEYBOARDS.map((product) => [product.productId, product] as const)); + +export function atkKeyboardProduct(productId: number): AtkKeyboardProduct | undefined { + return BY_PRODUCT_ID.get(productId); +} diff --git a/src/atk/protocol.test.ts b/src/atk/protocol.test.ts new file mode 100644 index 0000000..7b2f880 --- /dev/null +++ b/src/atk/protocol.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ATK_ADVANCED_MODE, ATK_COMMAND, ATK_CUSTOM_ID, ATK_FRAME_LENGTH, ATK_KEYBOARDS, ATK_LIGHT_ZONE, ATK_SLEEP_NEVER, ATK_VENDOR_ID, + atkBuildClearAdvancedKey, atkBuildGetAdvancedKey, atkBuildGetDeadZone, atkBuildGetFeature, atkBuildGetFirmware, atkBuildGetFlag, + atkBuildGetKeyLight, atkBuildGetKeymapChunk, atkBuildGetKeyRapidTrigger, atkBuildGetKeyTravel, atkBuildGetLightConfig, + atkBuildGetProtocolVersion, atkBuildGetReportRate, atkBuildGetSleep, atkBuildGetTravelLimits, atkBuildGetZoneLight, atkBuildMt, + atkBuildSetAdvancedKey, atkBuildSetDeadZone, atkBuildSetDks, atkBuildSetFeature, atkBuildSetFlag, atkBuildSetKeyLight, + atkBuildSetKeyRapidTrigger, atkBuildSetKeyTravel, atkBuildSetLightConfig, atkBuildSetReportRate, atkBuildSetSleep, + atkBuildSetZoneLight, atkBuildTgl, atkCustomGet, atkCustomSet, atkDecodeAdvancedKey, atkDecodeDeadZone, atkDecodeFeature, + atkDecodeFirmware, atkDecodeFlag, atkDecodeKeyLight, atkDecodeKeymap, atkDecodeKeyRapidTrigger, atkDecodeKeyTravel, + atkDecodeLightConfig, atkDecodeProtocolVersion, atkDecodeReportRate, atkDecodeSleep, atkDecodeTravelLimits, atkDecodeZoneLight, + atkFrame, atkIsFirmwareV2, atkKeyboardProduct, atkKeymapChunks, atkReplyMatches, atkZoneChannel, +} from "./index.ts"; + +const hex = (frame: Uint8Array): string => Buffer.from(frame).toString("hex"); + +function reply(...bytes: number[]): Uint8Array { + const frame = new Uint8Array(ATK_FRAME_LENGTH); + bytes.forEach((value, index) => { frame[index] = value; }); + return frame; +} + +const ORACLE: ReadonlyArray = [ + ["protocol", atkBuildGetProtocolVersion(), "0100000000000000000000000000000000000000000000000000000000000000"], + ["version", atkBuildGetFirmware(), "0296000000000000000000000000000000000000000000000000000000000000"], + ["travelInfo", atkBuildGetTravelLimits(), "0296240000000000000000000000000000000000000000000000000000000000"], + ["deadGet", atkBuildGetDeadZone(), "02960b0000000000000000000000000000000000000000000000000000000000"], + ["deadSet", atkBuildSetDeadZone({ top: 0.35, bottom: 0.2 }), "03960b015e00c800000000000000000000000000000000000000000000000000"], + ["featureGet", atkBuildGetFeature(), "0296110000000000000000000000000000000000000000000000000000000000"], + ["featureSet", atkBuildSetFeature({ rgbArea: 1, exchangeWasd: 1, macMode: 0, winLock: 1, nkro: 2 }), "0396110101000102000000000000000000000000000000000000000000000000"], + ["flagGet", atkBuildGetFlag("gameMode"), "02961e0000000000000000000000000000000000000000000000000000000000"], + ["flagSet", atkBuildSetFlag("gameMode", true), "03961e0100000000000000000000000000000000000000000000000000000000"], + ["sleepGet", atkBuildGetSleep(), "0296260000000000000000000000000000000000000000000000000000000000"], + ["sleepSet", atkBuildSetSleep(300), "0396260500000000000000000000000000000000000000000000000000000000"], + ["sleepNever", atkBuildSetSleep(ATK_SLEEP_NEVER), "0396260000000000000000000000000000000000000000000000000000000000"], + ["rateGet", atkBuildGetReportRate(), "0296460000000000000000000000000000000000000000000000000000000000"], + ["lightGet", atkBuildGetLightConfig(), "0841000000000000000000000000000000000000000000000000000000000000"], + ["lightSet", atkBuildSetLightConfig({ effect: 6, speed: 3, color: [10, 20, 30], brightness: 200, colorful: true }), "07410600030a141ec80100000000000000000000000000000000000000000000"], + ["zoneGetV1", atkBuildGetZoneLight(ATK_LIGHT_ZONE.logo, false), "0296210000000000000000000000000000000000000000000000000000000000"], + ["zoneSetV1", atkBuildSetZoneLight(ATK_LIGHT_ZONE.side, false, { effect: 2, colorful: false, brightness: 5, speed: 4, color: [1, 2, 3] }), "0396270200050401020300000000000000000000000000000000000000000000"], + ["zoneGetV2", atkBuildGetZoneLight(ATK_LIGHT_ZONE.front, true), "029653f200000000000000000000000000000000000000000000000000000000"], + ["zoneSetV2", atkBuildSetZoneLight(ATK_LIGHT_ZONE.button, true, { effect: 3, colorful: true, brightness: 9, speed: 8, color: [7, 8, 9] }), "03965303010908070809f3000000000000000000000000000000000000000000"], + ["keyLightGet", atkBuildGetKeyLight(12), "02964b0c00000000000000000000000000000000000000000000000000000000"], + ["keyLightSet", atkBuildSetKeyLight(12, [1, 2, 3]), "03964b0c01020300000000000000000000000000000000000000000000000000"], + ["travelGet", atkBuildGetKeyTravel(2, 5), "0296030205000000000000000000000000000000000000000000000000000000"], + ["travelSet", atkBuildSetKeyTravel(2, 5, 1.25), "039603020504e200000000000000000000000000000000000000000000000000"], + ["rtGet", atkBuildGetKeyRapidTrigger(2, 5), "0296070205000000000000000000000000000000000000000000000000000000"], + ["rtSet", atkBuildSetKeyRapidTrigger(2, 5, { enabled: true, press: 0.3, release: 0.2 }), "03960702050100c8012c00000000000000000000000000000000000000000000"], + ["advGet", atkBuildGetAdvancedKey(4), "0296200000010400040000000000000000000000000000000000000000000000"], + ["advSetRs", atkBuildSetAdvancedKey(4, { mode: 1, point: 1.5, rt: true, key1: [0, 0x1a], key2: [0, 0x16], layer: 0 }), "03962000000304010405dc0001001a0016000000000000000000000000000000"], + ["advSetOks", atkBuildSetAdvancedKey(7, { mode: 6, point: 0.8, key1: [0, 4], key2: [0, 5], layer: 1, triggerDelay: 300, keyDelay: 20 }), "039620000003070607032000000004000501012c001400000000000000000000"], + ["advClear", atkBuildClearAdvancedKey(4), "0396200000030400040000000000000000000000000000000000000000000000"], + ["mt", atkBuildMt(3, 0x04, 0xe1, 200), "fe0d0203000400e100c800000000000000000000000000000000000000000000"], + ["tgl", atkBuildTgl(3, 0x1a, 150), "fe0d0217001a0000009600000000000000000000000000000000000000000000"], + ["dks", atkBuildSetDks(2, [0x04, 0x05, 0, 0x07], 1.2, 3.1), "03960f000003020f0000040f000005000000000f00000704b00c1c0000000000"], + ["rateSet restart", atkBuildSetReportRate(2000)[0]!, "03964e0100000000000000000000000000000000000000000000000000000000"], + ["rateSet rate", atkBuildSetReportRate(2000)[1]!, "0396460200000000000000000000000000000000000000000000000000000000"], + ["keymap chunk 0", atkBuildGetKeymapChunk(0, 28), "1200001c00000000000000000000000000000000000000000000000000000000"], + ["keymap chunk 1", atkBuildGetKeymapChunk(28, 28), "12001c1c00000000000000000000000000000000000000000000000000000000"], +]; + +test("request frames match the OpenATK keyboard configurator byte for byte", () => { + for (const [name, frame, expected] of ORACLE) assert.equal(hex(frame), expected, name); +}); + +test("frames are always 32 bytes and reject payloads that do not fit", () => { + assert.equal(atkFrame(1).length, ATK_FRAME_LENGTH); + assert.throws(() => atkFrame(1, new Array(32).fill(0)), RangeError); + assert.throws(() => atkFrame(300), RangeError); +}); + +test("replies are matched by command, then by the custom channel", () => { + const get = atkCustomGet(0x24); + assert.equal(atkReplyMatches(get, reply(ATK_COMMAND.getKeyboardValue, ATK_CUSTOM_ID, 0x24, 0, 5)), true); + assert.equal(atkReplyMatches(get, reply(ATK_COMMAND.getKeyboardValue, ATK_CUSTOM_ID, 0x0b)), false); + assert.equal(atkReplyMatches(get, reply(ATK_COMMAND.getKeyboardValue, 0x00, 0x24)), false); + assert.equal(atkReplyMatches(get, reply(ATK_COMMAND.getProtocolVersion)), false); + assert.equal(atkReplyMatches(atkBuildGetProtocolVersion(), reply(ATK_COMMAND.getProtocolVersion, 0, 9)), true); + assert.equal(atkReplyMatches(atkBuildGetLightConfig(), reply(ATK_COMMAND.lightingGetValue, 0x41)), true); +}); + +test("protocol version and firmware decode", () => { + assert.equal(atkDecodeProtocolVersion(reply(1, 0, 9)), 9); + const firmware = atkDecodeFirmware(reply(2, ATK_CUSTOM_ID, 0, 0, 0, 0x02, 0x10)); + assert.equal(firmware.text, "2.10"); + assert.equal(firmware.value, 2.1); + assert.equal(atkIsFirmwareV2(firmware), true); + assert.equal(atkIsFirmwareV2(atkDecodeFirmware(reply(2, ATK_CUSTOM_ID, 0, 0, 0, 0x01, 0x09))), false); + assert.equal(atkIsFirmwareV2(null), false); +}); + +test("travel limits, dead zone, and per-key travel decode in millimetres", () => { + const limits = atkDecodeTravelLimits(reply(2, ATK_CUSTOM_ID, 0x24, 0x0d, 0x48, 0x00, 0x64, 0x00, 0x14, 0x0d, 0x48, 0x00, 0x01, 0x00, 0x01)); + assert.deepEqual(limits, { travelMax: 3.4, travelMin: 0.1, travelStep: 0.02, rtMax: 3.4, rtMin: 0.001, rtStep: 0.001 }); + assert.deepEqual(atkDecodeDeadZone(reply(2, ATK_CUSTOM_ID, 0x0b, 0x01, 0x5e, 0x00, 0xc8)), { top: 0.35, bottom: 0.2 }); + assert.equal(atkDecodeKeyTravel(reply(2, ATK_CUSTOM_ID, 0x03, 2, 5, 0x04, 0xe2)), 1.25); +}); + +test("rapid trigger and advanced key replies decode", () => { + assert.deepEqual(atkDecodeKeyRapidTrigger(reply(2, ATK_CUSTOM_ID, 0x07, 2, 5, 1, 0x00, 0xc8, 0x01, 0x2c)), { enabled: true, release: 0.2, press: 0.3 }); + const advanced = atkDecodeAdvancedKey(4, reply(2, ATK_CUSTOM_ID, 0x20, 0, 0, 1, 4, 1, 4, 0x05, 0xdc, 0, 1, 0, 0x1a, 0, 0x16, 0)); + assert.deepEqual(advanced, { index: 4, mode: ATK_ADVANCED_MODE.rs, point: 1.5, rt: true, key1: [0, 0x1a], key2: [0, 0x16], layer: 0 }); +}); + +test("feature, flag, sleep, and polling replies decode", () => { + assert.deepEqual(atkDecodeFeature(reply(2, ATK_CUSTOM_ID, 0x11, 1, 1, 0, 1, 2, 3)), { rgbArea: 1, exchangeWasd: 1, macMode: 0, winLock: 1, nkro: 2, keyMode: 3 }); + assert.equal(atkDecodeFlag(reply(2, ATK_CUSTOM_ID, 0x1e, 1)), true); + assert.equal(atkDecodeFlag(reply(2, ATK_CUSTOM_ID, 0x1e, 0)), false); + assert.equal(atkDecodeSleep(reply(2, ATK_CUSTOM_ID, 0x26, 5)), 300); + assert.equal(atkDecodeSleep(reply(2, ATK_CUSTOM_ID, 0x26, 0)), ATK_SLEEP_NEVER); + assert.equal(atkDecodeReportRate(reply(2, ATK_CUSTOM_ID, 0x46, 0)), 8000); + assert.equal(atkDecodeReportRate(reply(2, ATK_CUSTOM_ID, 0x46, 3)), 1000); + assert.equal(atkDecodeReportRate(reply(2, ATK_CUSTOM_ID, 0x46, 9)), null); +}); + +test("lighting replies decode", () => { + assert.deepEqual(atkDecodeLightConfig(reply(8, 0x41, 6, 0, 3, 10, 20, 30, 200, 1)), { effect: 6, speed: 3, color: [10, 20, 30], brightness: 200, colorful: true }); + assert.deepEqual(atkDecodeZoneLight(reply(2, ATK_CUSTOM_ID, 0x53, 3, 1, 9, 8, 7, 8, 9)), { effect: 3, colorful: true, brightness: 9, speed: 8, color: [7, 8, 9] }); + assert.deepEqual(atkDecodeKeyLight(reply(2, ATK_CUSTOM_ID, 0x4b, 12, 1, 2, 3)), { key: 12, color: [1, 2, 3] }); +}); + +test("older firmware keeps the logo and side lights on their own channels", () => { + assert.equal(atkZoneChannel(ATK_LIGHT_ZONE.logo, false), 0x21); + assert.equal(atkZoneChannel(ATK_LIGHT_ZONE.side, false), 0x27); + assert.equal(atkZoneChannel(ATK_LIGHT_ZONE.front, false), 0x53); + assert.equal(atkZoneChannel(ATK_LIGHT_ZONE.logo, true), 0x53); +}); + +test("keymap chunks cover the matrix and decode big-endian codes", () => { + const chunks = atkKeymapChunks(5, 14); + assert.equal(chunks.length, 5); + assert.deepEqual(chunks[4], { offset: 112, length: 28 }); + assert.deepEqual(atkKeymapChunks(1, 3), [{ offset: 0, length: 6 }]); + assert.deepEqual(atkDecodeKeymap([0x00, 0x29, 0x01, 0x04], 1, 2), [0x29, 0x104]); + assert.throws(() => atkBuildGetKeymapChunk(0, 29), RangeError); +}); + +test("writes reject out-of-range values", () => { + assert.throws(() => atkBuildSetSleep(0), RangeError); + assert.throws(() => atkBuildSetSleep(60 * 256), RangeError); + assert.throws(() => atkBuildSetReportRate(500), RangeError); + assert.throws(() => atkBuildSetKeyTravel(0, 0, -1), RangeError); + assert.throws(() => atkBuildSetKeyTravel(0, 0, 70), RangeError); + assert.throws(() => atkBuildSetLightConfig({ effect: 300, speed: 0, color: [0, 0, 0], brightness: 0, colorful: false }), RangeError); + assert.throws(() => atkBuildSetFeature({ rgbArea: 0, exchangeWasd: 0, macMode: 0, winLock: 0, nkro: 3 }), RangeError); + assert.throws(() => atkBuildSetDks(0, [1, 2, 3, 4, 5], 1, 1), RangeError); + assert.throws(() => atkBuildTgl(0, 4, 70000), RangeError); +}); + +test("the catalog is unique, ATK-only, and leaves out the QMK-based Hex80 boards", () => { + const ids = ATK_KEYBOARDS.map((product) => product.productId); + assert.equal(new Set(ids).size, ids.length); + assert.equal(ATK_VENDOR_ID, 0x373b); + assert.equal(ATK_KEYBOARDS.some((product) => /hex80|qk/i.test(product.name)), false); + assert.equal(atkKeyboardProduct(0x1176), undefined); + assert.equal(atkKeyboardProduct(0x10b0)?.name, "EDGE 60HE ULTIMATE"); + assert.deepEqual([atkKeyboardProduct(0x10b0)?.rows, atkKeyboardProduct(0x10b0)?.cols], [5, 14]); + assert.equal(atkCustomSet(1, [2]).length, ATK_FRAME_LENGTH); +}); diff --git a/src/drivers/atk/hid.test.ts b/src/drivers/atk/hid.test.ts new file mode 100644 index 0000000..e10b11e --- /dev/null +++ b/src/drivers/atk/hid.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ATK_CUSTOM_ID, ATK_FRAME_LENGTH, ATK_LIGHT_ZONE, ATK_SLEEP_NEVER, ATK_VENDOR_ID } from "../../atk/index.ts"; +import { AtkKeyboardHidClient } from "./hid.ts"; +import { clientSupportScore, createSupportedClient, deviceBrand } from "../registry.ts"; +import { ATK_HID_FILTERS, SUPPORTED_HID_FILTERS } from "../vendors.ts"; + +class FakeAtkKeyboard { + vendorId = ATK_VENDOR_ID; + productId = 0x10b0; + productName = "EDGE 60HE ULTIMATE"; + opened = false; + collections = [{ usagePage: 0xff60, usage: 0x61, inputReports: [], outputReports: [], featureReports: [], children: [] }]; + state = new Map(); + sent: Uint8Array[] = []; + silent = new Set(); + ignoreWrites = new Set(); + firmware = [2, 0x10]; + listeners = new Set<(event: HIDInputReportEvent) => void>(); + get hid(): HIDDevice { return this as unknown as HIDDevice; } + async open() { this.opened = true; } + async close() { this.opened = false; } + addEventListener(_type: string, listener: (event: HIDInputReportEvent) => void) { this.listeners.add(listener); } + removeEventListener(_type: string, listener: (event: HIDInputReportEvent) => void) { this.listeners.delete(listener); } + + private key(channel: number, bytes: number[]): string { + return channel === 0x03 || channel === 0x07 ? `${channel}:${bytes[0]},${bytes[1]}` : `${channel}`; + } + + private emit(frame: Uint8Array) { + const event = { device: this.hid, reportId: 0, data: new DataView(frame.buffer) } as HIDInputReportEvent; + this.listeners.forEach((listener) => listener(event)); + } + + async sendReport(reportId: number, buffer: BufferSource) { + assert.equal(reportId, 0); + const request = Uint8Array.from(buffer as Uint8Array); + assert.equal(request.length, ATK_FRAME_LENGTH); + this.sent.push(request); + const channel = request[2]!; + if (this.silent.has(request[0]! * 256 + (request[1] === ATK_CUSTOM_ID ? channel : 0))) return; + const reply = new Uint8Array(ATK_FRAME_LENGTH); + reply[0] = request[0]!; + if (request[0] === 0x01) { + reply[2] = 9; + } else if (request[0] === 0x08) { + reply[1] = 0x41; + reply.set(this.state.get("light") ?? [], 2); + } else if (request[0] === 0x07) { + this.state.set("light", Array.from(request.subarray(2, 10))); + queueMicrotask(() => this.emit(Uint8Array.from(request))); + return; + } else if (request[0] === 0x02 && request[1] === ATK_CUSTOM_ID && channel === 0) { + reply[1] = ATK_CUSTOM_ID; + reply[5] = this.firmware[0]!; + reply[6] = this.firmware[1]!; + } else if (request[1] === ATK_CUSTOM_ID) { + reply[1] = ATK_CUSTOM_ID; + reply[2] = channel; + if (request[0] === 0x03) { + if (!this.ignoreWrites.has(channel)) this.state.set(this.key(channel, Array.from(request.subarray(3))), Array.from(request.subarray(3, 20))); + queueMicrotask(() => this.emit(Uint8Array.from(request))); + return; + } + const stored = this.state.get(this.key(channel, Array.from(request.subarray(3)))) ?? []; + reply.set(channel === 0x03 || channel === 0x07 ? stored : stored, 3); + } + queueMicrotask(() => this.emit(reply)); + } +} + +function setup(): { fake: FakeAtkKeyboard; client: AtkKeyboardHidClient } { + const fake = new FakeAtkKeyboard(); + return { fake, client: new AtkKeyboardHidClient(fake.hid) }; +} + +test("only catalogued ATK keyboards on the raw-HID collection are claimed", () => { + assert.equal(AtkKeyboardHidClient.isSupported(setup().fake.hid), true); + + const otherVendor = new FakeAtkKeyboard(); + otherVendor.vendorId = 0x7470; + assert.equal(AtkKeyboardHidClient.isSupported(otherVendor.hid), false); + + const uncatalogued = new FakeAtkKeyboard(); + uncatalogued.productId = 0x9999; + assert.equal(AtkKeyboardHidClient.isSupported(uncatalogued.hid), false); + + const hex80 = new FakeAtkKeyboard(); + hex80.productId = 0x1176; + assert.equal(AtkKeyboardHidClient.isSupported(hex80.hid), false); + + const noCollection = new FakeAtkKeyboard(); + noCollection.collections = []; + assert.equal(AtkKeyboardHidClient.isSupported(noCollection.hid), false); +}); + +test("the registry hands an ATK keyboard to the ATK driver ahead of generic VIA", () => { + const { fake } = setup(); + const client = createSupportedClient(fake.hid); + assert.ok(client instanceof AtkKeyboardHidClient); + assert.equal(deviceBrand(client), "ATK"); + assert.equal(clientSupportScore(fake.hid), 6); +}); + +test("the picker filters name every catalogued ATK product", () => { + assert.ok(ATK_HID_FILTERS.length > 100); + assert.ok(ATK_HID_FILTERS.every((filter) => filter.vendorId === ATK_VENDOR_ID && filter.usagePage === 0xff60 && filter.usage === 0x61)); + assert.ok(ATK_HID_FILTERS.every((filter) => SUPPORTED_HID_FILTERS.includes(filter))); + assert.equal(ATK_HID_FILTERS.some((filter) => filter.productId === 0x1176), false); +}); + +test("readStatus reads identity and settings from the custom channels", async () => { + const { fake, client } = setup(); + fake.state.set("36", [0x0d, 0x48, 0x00, 0x64, 0x00, 0x14, 0x0d, 0x48, 0x00, 0x01, 0x00, 0x01]); + fake.state.set("11", [0x01, 0x5e, 0x00, 0xc8]); + fake.state.set("17", [1, 0, 1, 0, 2, 3]); + fake.state.set("30", [1]); + fake.state.set("38", [5]); + fake.state.set("70", [2]); + fake.state.set("light", [6, 0, 3, 10, 20, 30, 200, 1]); + const status = await client.readStatus(); + assert.equal(status.brand, "ATK"); + assert.equal(status.name, "EDGE 60HE ULTIMATE"); + assert.equal(status.connectionType, "Wired"); + assert.equal(status.firmwareVersion, "2.10"); + assert.equal(status.layout, "5x14 matrix"); + assert.equal(status.atk?.protocolVersion, 9); + assert.equal(status.atk?.reportRate, 2000); + assert.equal(status.atk?.gameMode, true); + assert.equal(status.atk?.sleepSeconds, 300); + assert.deepEqual(status.atk?.deadZone, { top: 0.35, bottom: 0.2 }); + assert.equal(status.atk?.feature?.nkro, 2); + assert.deepEqual(status.atk?.travelLimits, { travelMax: 3.4, travelMin: 0.1, travelStep: 0.02, rtMax: 3.4, rtMin: 0.001, rtStep: 0.001 }); + assert.deepEqual(status.atk?.light, { effect: 6, speed: 3, color: [10, 20, 30], brightness: 200, colorful: true }); +}); + +test("a 2.4G variant is reported as wireless", async () => { + const { fake, client } = setup(); + fake.productId = 0x1242; + fake.productName = "ATK A87 Ultra 2.4G"; + const status = await client.readStatus(); + assert.equal(status.connectionType, "Wireless"); + assert.equal(status.connectionDetail, "2.4 GHz receiver"); +}); + +test("setReportRate writes the restart flag then the rate, and confirms by reading back", async () => { + const { fake, client } = setup(); + assert.equal(await client.setReportRate(4000), 4000); + const writes = fake.sent.filter((frame) => frame[0] === 0x03).map((frame) => [frame[2], frame[3]]); + assert.deepEqual(writes, [[0x4e, 1], [0x46, 1]]); +}); + +test("setReportRate fails when the keyboard keeps a different rate", async () => { + const { fake, client } = setup(); + fake.ignoreWrites.add(0x46); + fake.state.set("70", [3]); + await assert.rejects(client.setReportRate(8000), /kept 1000 instead of 8000/); +}); + +test("setSleep, dead zone, flags and feature writes round-trip through read-back", async () => { + const { client } = setup(); + assert.equal(await client.setSleep(300), 300); + assert.equal(await client.setSleep(ATK_SLEEP_NEVER), ATK_SLEEP_NEVER); + assert.deepEqual(await client.setDeadZone({ top: 0.35, bottom: 0.2 }), { top: 0.35, bottom: 0.2 }); + assert.equal(await client.setFlag("gameMode", true), true); + const feature = await client.setFeature({ winLock: 1, nkro: 2 }); + assert.equal(feature.winLock, 1); + assert.equal(feature.nkro, 2); +}); + +test("setFlag fails when the keyboard refuses the change", async () => { + const { fake, client } = setup(); + fake.ignoreWrites.add(0x1e); + await assert.rejects(client.setFlag("gameMode", true), /kept gameMode off/); +}); + +test("per-key travel and rapid trigger are bounds-checked against the matrix and read back per key", async () => { + const { client } = setup(); + assert.equal(await client.setKeyTravel(2, 5, 1.25), 1.25); + assert.deepEqual(await client.setKeyRapidTrigger(2, 5, { enabled: true, press: 0.3, release: 0.2 }), { enabled: true, press: 0.3, release: 0.2 }); + assert.throws(() => client.readKeyTravel(5, 0), /outside this keyboard's 5 rows/); + assert.throws(() => client.setKeyTravel(0, 14, 1), /outside this keyboard's 14 columns/); + assert.throws(() => client.readKeyTravel(-1, 0), RangeError); +}); + +test("zone lights use the decoration channel on v2 firmware and the logo channel on older firmware", async () => { + const light = { effect: 3, colorful: true, brightness: 9, speed: 8, color: [7, 8, 9] as const }; + const v2 = setup(); + await v2.client.setZoneLight(ATK_LIGHT_ZONE.button, light); + assert.equal(v2.fake.sent.find((frame) => frame[0] === 0x03)![2], 0x53); + + const old = setup(); + old.fake.firmware = [1, 0x09]; + await old.client.setZoneLight(ATK_LIGHT_ZONE.logo, light); + assert.equal(old.fake.sent.find((frame) => frame[0] === 0x03)![2], 0x21); +}); + +test("an unanswered command fails after three tries and later commands still work", async () => { + const { fake, client } = setup(); + fake.silent.add(0x02 * 256 + 0x0b); + await assert.rejects(client.setDeadZone({ top: 0.1, bottom: 0.1 }), /did not answer command 0x3 after 3 tries|did not answer command 0x2 after 3 tries/); + fake.silent.clear(); + assert.equal(await client.setFlag("aiMatch", true), true); +}); + +test("the keymap is read in 28-byte chunks and decoded", async () => { + const { fake, client } = setup(); + const original = fake.sendReport.bind(fake); + fake.sendReport = async (reportId: number, buffer: BufferSource) => { + const request = buffer as Uint8Array; + if (request[0] !== 0x12) return original(reportId, buffer); + fake.sent.push(Uint8Array.from(request)); + const offset = (request[1]! << 8) | request[2]!; + const reply = new Uint8Array(ATK_FRAME_LENGTH); + reply[0] = 0x12; + for (let i = 0; i < request[3]!; i++) reply[4 + i] = (offset + i) % 2 === 0 ? 0 : 0x04 + Math.floor((offset + i) / 2); + queueMicrotask(() => fake["emit"](reply)); + }; + const map = await client.readKeymap(); + assert.equal(map.length, 70); + assert.equal(map[0], 0x04); + assert.equal(map[69], 0x04 + 69); + assert.equal(fake.sent.filter((frame) => frame[0] === 0x12).length, 5); +}); + +test("closing the client ends the session", async () => { + const { fake, client } = setup(); + await client.open(); + await client.close(); + assert.equal(fake.opened, false); + await assert.rejects(client.open(), /session was closed/); +}); diff --git a/src/drivers/atk/hid.ts b/src/drivers/atk/hid.ts new file mode 100644 index 0000000..0fccefd --- /dev/null +++ b/src/drivers/atk/hid.ts @@ -0,0 +1,320 @@ +import type { KeyboardStatus } from "../keyboard-types.ts"; +import { + ATK_ADVANCED_MODE, ATK_CONFIG_USAGE, ATK_SLEEP_NEVER, ATK_CONFIG_USAGE_PAGE, ATK_REPORT_ID, ATK_VENDOR_ID, + atkBuildClearAdvancedKey, atkBuildGetAdvancedKey, atkBuildGetDeadZone, atkBuildGetFeature, atkBuildGetFirmware, + atkBuildGetFlag, atkBuildGetKeyLight, atkBuildGetKeymapChunk, atkBuildGetKeyRapidTrigger, atkBuildGetKeyTravel, + atkBuildGetLightConfig, atkBuildGetProtocolVersion, atkBuildGetReportRate, atkBuildGetSleep, atkBuildGetTravelLimits, + atkBuildGetZoneLight, atkBuildMt, atkBuildSetAdvancedKey, atkBuildSetDeadZone, atkBuildSetDks, atkBuildSetFeature, + atkBuildSetFlag, atkBuildSetKeyLight, atkBuildSetKeyRapidTrigger, atkBuildSetKeyTravel, atkBuildSetLightConfig, + atkBuildSetReportRate, atkBuildSetSleep, atkBuildSetZoneLight, atkBuildTgl, atkDecodeAdvancedKey, atkDecodeDeadZone, + atkDecodeFeature, atkDecodeFirmware, atkDecodeFlag, atkDecodeKeyLight, atkDecodeKeymap, atkDecodeKeymapChunk, + atkDecodeKeyRapidTrigger, atkDecodeKeyTravel, atkDecodeLightConfig, atkDecodeProtocolVersion, atkDecodeReportRate, + atkDecodeSleep, atkDecodeTravelLimits, atkDecodeZoneLight, atkIsFirmwareV2, atkKeyboardProduct, atkKeymapChunks, + atkReplyMatches, + type AtkAdvancedKey, type AtkAdvancedKeyWrite, type AtkDeadZone, type AtkFeature, type AtkFirmwareVersion, + type AtkFlag, type AtkLightConfig, type AtkLightZone, type AtkRapidTrigger, type AtkRgb, type AtkTravelLimits, + type AtkZoneLight, +} from "@openmouse/keyboard-protocol/atk"; + +const ATTEMPTS = 3; +const REPLY_TIMEOUT_MS = 1200; + +export interface AtkKeyboardState { + productId: number; + protocolVersion: number | null; + travelLimits: AtkTravelLimits | null; + deadZone: AtkDeadZone | null; + feature: AtkFeature | null; + gameMode: boolean | null; + aiMatch: boolean | null; + bottomOptimize: boolean | null; + reportRate: number | null; + sleepSeconds: number | null; + light: AtkLightConfig | null; +} + +export class AtkKeyboardHidClient { + readonly device: HIDDevice; + private queue: Promise = Promise.resolve(); + private listening = false; + private faulted = false; + private firmware: AtkFirmwareVersion | null = null; + private pending: { sent: Uint8Array; resolve: (frame: Uint8Array) => void } | null = null; + + constructor(device: HIDDevice) { this.device = device; } + + static isSupported(device: HIDDevice): boolean { + return device.vendorId === ATK_VENDOR_ID + && atkKeyboardProduct(device.productId) !== undefined + && AtkKeyboardHidClient.hasConfigCollection(device.collections); + } + + private static hasConfigCollection(collections: readonly HIDCollectionInfo[]): boolean { + return collections.some((collection) => + (collection.usagePage === ATK_CONFIG_USAGE_PAGE && collection.usage === ATK_CONFIG_USAGE) + || AtkKeyboardHidClient.hasConfigCollection(collection.children)); + } + + private get product() { + return atkKeyboardProduct(this.device.productId); + } + + async open(): Promise { + if (!AtkKeyboardHidClient.isSupported(this.device)) throw new Error("Not an ATK keyboard configuration interface."); + if (this.faulted) throw new Error("This keyboard session was closed; reconnect to continue."); + if (!this.device.opened) await this.device.open(); + if (this.faulted) { + if (this.device.opened) await this.device.close(); + throw new Error("This keyboard session was closed; reconnect to continue."); + } + if (!this.listening) { + this.device.addEventListener("inputreport", this.onReport); + this.listening = true; + } + } + + async close(): Promise { + this.faulted = true; + this.pending = null; + if (this.listening) this.device.removeEventListener("inputreport", this.onReport); + this.listening = false; + if (this.device.opened) await this.device.close(); + } + + private readonly onReport = (event: HIDInputReportEvent): void => { + if (event.device !== this.device || !this.pending) return; + const frame = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength); + if (atkReplyMatches(this.pending.sent, frame)) this.pending.resolve(frame.slice()); + }; + + private serialized(operation: () => Promise): Promise { + const next = this.queue.then(operation); + this.queue = next.catch(() => undefined); + return next; + } + + private async exchange(frame: Uint8Array): Promise { + await this.open(); + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + let timer: ReturnType | undefined; + const reply = new Promise((resolve) => { + this.pending = { sent: frame, resolve }; + timer = setTimeout(() => resolve(null), REPLY_TIMEOUT_MS); + }); + try { + await this.device.sendReport(ATK_REPORT_ID, frame as BufferSource); + const got = await reply; + if (got) return got; + } catch (error) { + if (attempt === ATTEMPTS) throw error; + } finally { + clearTimeout(timer); + this.pending = null; + } + } + throw new Error(`The keyboard did not answer command 0x${frame[0]!.toString(16)} after ${ATTEMPTS} tries.`); + } + + private async quietly(read: () => Promise): Promise { + try { + return await read(); + } catch (error) { + if (this.faulted) throw error; + return null; + } + } + + private async v2(): Promise { + if (!this.firmware) this.firmware = atkDecodeFirmware(await this.exchange(atkBuildGetFirmware())); + return atkIsFirmwareV2(this.firmware); + } + + private checkKey(row: number, col: number): void { + const product = this.product; + if (!Number.isInteger(row) || !Number.isInteger(col) || row < 0 || col < 0) throw new RangeError("Key row and column must be non-negative integers"); + if (product?.rows != null && row >= product.rows) throw new RangeError(`Row ${row} is outside this keyboard's ${product.rows} rows`); + if (product?.cols != null && col >= product.cols) throw new RangeError(`Column ${col} is outside this keyboard's ${product.cols} columns`); + } + + readStatus(): Promise { + return this.serialized(async () => { + await this.open(); + const firmware = await this.quietly(async () => atkDecodeFirmware(await this.exchange(atkBuildGetFirmware()))); + this.firmware = firmware; + const product = this.product!; + const wireless = /2\.4G/i.test(product.name); + const atk: AtkKeyboardState = { + productId: this.device.productId, + protocolVersion: await this.quietly(async () => atkDecodeProtocolVersion(await this.exchange(atkBuildGetProtocolVersion()))), + travelLimits: await this.quietly(async () => atkDecodeTravelLimits(await this.exchange(atkBuildGetTravelLimits()))), + deadZone: await this.quietly(async () => atkDecodeDeadZone(await this.exchange(atkBuildGetDeadZone()))), + feature: await this.quietly(async () => atkDecodeFeature(await this.exchange(atkBuildGetFeature()))), + gameMode: await this.quietly(async () => atkDecodeFlag(await this.exchange(atkBuildGetFlag("gameMode")))), + aiMatch: await this.quietly(async () => atkDecodeFlag(await this.exchange(atkBuildGetFlag("aiMatch")))), + bottomOptimize: await this.quietly(async () => atkDecodeFlag(await this.exchange(atkBuildGetFlag("bottomOptimize")))), + reportRate: await this.quietly(async () => atkDecodeReportRate(await this.exchange(atkBuildGetReportRate()))), + sleepSeconds: await this.quietly(async () => atkDecodeSleep(await this.exchange(atkBuildGetSleep()))), + light: await this.quietly(async () => atkDecodeLightConfig(await this.exchange(atkBuildGetLightConfig()))), + }; + return { + brand: "ATK", + name: product.name, + connectionType: wireless ? "Wireless" : "Wired", + connectionDetail: wireless ? "2.4 GHz receiver" : "USB", + firmware: firmware ? [`Firmware ${firmware.text}`] : [], + firmwareVersion: firmware?.text ?? null, + layout: product.rows != null && product.cols != null ? `${product.rows}x${product.cols} matrix` : null, + serial: null, + activeProfile: null, + profileCount: null, + profileNames: [], + ui: { family: "atk", defaultDisplayName: product.name }, + atk, + }; + }); + } + + setReportRate(hz: number): Promise { + return this.serialized(async () => { + for (const frame of atkBuildSetReportRate(hz)) await this.exchange(frame); + const confirmed = atkDecodeReportRate(await this.exchange(atkBuildGetReportRate())); + if (confirmed !== hz) throw new Error(`The keyboard kept ${confirmed ?? "an unknown rate"} instead of ${hz} Hz.`); + return confirmed; + }); + } + + setSleep(seconds: number): Promise { + return this.serialized(async () => { + await this.exchange(atkBuildSetSleep(seconds)); + const confirmed = atkDecodeSleep(await this.exchange(atkBuildGetSleep())); + const expected = seconds === ATK_SLEEP_NEVER ? ATK_SLEEP_NEVER : Math.round(seconds / 60) * 60; + if (confirmed !== expected) throw new Error(`The keyboard kept ${confirmed} s instead of ${expected} s.`); + return confirmed; + }); + } + + setDeadZone(zone: AtkDeadZone): Promise { + return this.serialized(async () => { + await this.exchange(atkBuildSetDeadZone(zone)); + return atkDecodeDeadZone(await this.exchange(atkBuildGetDeadZone())); + }); + } + + setFeature(change: Partial>): Promise { + return this.serialized(async () => { + const current = atkDecodeFeature(await this.exchange(atkBuildGetFeature())); + await this.exchange(atkBuildSetFeature({ ...current, ...change })); + return atkDecodeFeature(await this.exchange(atkBuildGetFeature())); + }); + } + + setFlag(flag: AtkFlag, enabled: boolean): Promise { + return this.serialized(async () => { + await this.exchange(atkBuildSetFlag(flag, enabled)); + const confirmed = atkDecodeFlag(await this.exchange(atkBuildGetFlag(flag))); + if (confirmed !== enabled) throw new Error(`The keyboard kept ${flag} ${confirmed ? "on" : "off"}.`); + return confirmed; + }); + } + + setLightConfig(config: AtkLightConfig): Promise { + return this.serialized(async () => { + await this.exchange(atkBuildSetLightConfig(config)); + return atkDecodeLightConfig(await this.exchange(atkBuildGetLightConfig())); + }); + } + + readZoneLight(zone: AtkLightZone): Promise { + return this.serialized(async () => atkDecodeZoneLight(await this.exchange(atkBuildGetZoneLight(zone, await this.v2())))); + } + + setZoneLight(zone: AtkLightZone, light: AtkZoneLight): Promise { + return this.serialized(async () => { + const v2 = await this.v2(); + await this.exchange(atkBuildSetZoneLight(zone, v2, light)); + return atkDecodeZoneLight(await this.exchange(atkBuildGetZoneLight(zone, v2))); + }); + } + + readKeyLight(key: number): Promise<{ key: number; color: AtkRgb }> { + return this.serialized(async () => atkDecodeKeyLight(await this.exchange(atkBuildGetKeyLight(key)))); + } + + setKeyLight(key: number, color: AtkRgb): Promise<{ key: number; color: AtkRgb }> { + return this.serialized(async () => { + await this.exchange(atkBuildSetKeyLight(key, color)); + return atkDecodeKeyLight(await this.exchange(atkBuildGetKeyLight(key))); + }); + } + + readKeyTravel(row: number, col: number): Promise { + this.checkKey(row, col); + return this.serialized(async () => atkDecodeKeyTravel(await this.exchange(atkBuildGetKeyTravel(row, col)))); + } + + setKeyTravel(row: number, col: number, mm: number): Promise { + this.checkKey(row, col); + return this.serialized(async () => { + await this.exchange(atkBuildSetKeyTravel(row, col, mm)); + return atkDecodeKeyTravel(await this.exchange(atkBuildGetKeyTravel(row, col))); + }); + } + + readKeyRapidTrigger(row: number, col: number): Promise { + this.checkKey(row, col); + return this.serialized(async () => atkDecodeKeyRapidTrigger(await this.exchange(atkBuildGetKeyRapidTrigger(row, col)))); + } + + setKeyRapidTrigger(row: number, col: number, rapid: AtkRapidTrigger): Promise { + this.checkKey(row, col); + return this.serialized(async () => { + await this.exchange(atkBuildSetKeyRapidTrigger(row, col, rapid)); + return atkDecodeKeyRapidTrigger(await this.exchange(atkBuildGetKeyRapidTrigger(row, col))); + }); + } + + readAdvancedKey(index: number): Promise { + return this.serialized(async () => atkDecodeAdvancedKey(index, await this.exchange(atkBuildGetAdvancedKey(index)))); + } + + setAdvancedKey(index: number, write: AtkAdvancedKeyWrite): Promise { + return this.serialized(async () => { + await this.exchange(atkBuildSetAdvancedKey(index, write)); + return atkDecodeAdvancedKey(index, await this.exchange(atkBuildGetAdvancedKey(index))); + }); + } + + clearAdvancedKey(index: number): Promise { + return this.serialized(async () => { + await this.exchange(atkBuildClearAdvancedKey(index)); + const confirmed = atkDecodeAdvancedKey(index, await this.exchange(atkBuildGetAdvancedKey(index))); + if (confirmed.mode !== ATK_ADVANCED_MODE.none) throw new Error("The keyboard kept the advanced key."); + return confirmed; + }); + } + + setMt(index: number, tap: number, hold: number, delayMs: number): Promise { + return this.serialized(async () => { await this.exchange(atkBuildMt(index, tap, hold, delayMs)); }); + } + + setTgl(index: number, key: number, delayMs: number): Promise { + return this.serialized(async () => { await this.exchange(atkBuildTgl(index, key, delayMs)); }); + } + + setDks(index: number, codes: readonly number[], top: number, bottom: number): Promise { + return this.serialized(async () => { await this.exchange(atkBuildSetDks(index, codes, top, bottom)); }); + } + + readKeymap(): Promise { + const { rows, cols } = this.product ?? {}; + if (rows == null || cols == null) return Promise.reject(new Error("This keyboard's matrix size is not catalogued.")); + return this.serialized(async () => { + const bytes: number[] = []; + for (const { offset, length } of atkKeymapChunks(rows, cols)) { + bytes.push(...atkDecodeKeymapChunk(await this.exchange(atkBuildGetKeymapChunk(offset, length)), length)); + } + return atkDecodeKeymap(bytes, rows, cols); + }); + } +} + diff --git a/src/drivers/keyboard-types.ts b/src/drivers/keyboard-types.ts index 7ec5054..fddb508 100644 --- a/src/drivers/keyboard-types.ts +++ b/src/drivers/keyboard-types.ts @@ -38,6 +38,7 @@ import type { LogitechKeyboardState } from "@openmouse/keyboard-protocol/logitec import type { SteelSeriesApexProfile } from "@openmouse/keyboard-protocol/steelseries"; import type { KeychronHeFeatures } from "@openmouse/keyboard-protocol/keychron"; import type { ArbiterKeyboardState } from "@openmouse/keyboard-protocol/arbiter"; +import type { AtkKeyboardState } from "./atk/hid.ts"; export interface KeyboardUiHints { /** Stable driver id, e.g. "wooting". */ @@ -256,6 +257,8 @@ export interface KeyboardStatus { steelseries?: { productId: number; profile: SteelSeriesApexProfile | null; verified: boolean } | null; /** Arbiter Studio keyboard state (travel, light effect, SuperTap). */ arbiter?: ArbiterKeyboardState | null; + /** ATK keyboard settings read over the 0xFF60:0x61 channel with the ATK custom commands. */ + atk?: AtkKeyboardState | null; /** VIA/QMK board identity; keymap, macros and lighting are read on demand. */ via?: ViaStatus | null; } diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index ab24f26..20f0863 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -8,10 +8,12 @@ import { LogitechKeyboardHidClient } from "./logitech/hid.ts"; import { SteelSeriesKeyboardHidClient } from "./steelseries/hid.ts"; import { KeychronHidClient } from "./keychron/hid.ts"; import { ArbiterKeyboardHidClient } from "./arbiter/hid.ts"; +import { AtkKeyboardHidClient } from "./atk/hid.ts"; import { ViaHidClient } from "./via/hid.ts"; import { DEVICE_DRIVERS, clientSupportScore, createSupportedClient, deviceBrand } from "./registry.ts"; import { ARBITER_HID_FILTERS, + ATK_HID_FILTERS, SUPPORTED_HID_FILTERS, KEYCHRON_HID_FILTERS, KOCHI_HID_FILTERS, @@ -87,7 +89,7 @@ const LOGITECH_RECEIVER_DEVICE = (): HIDDevice => test("DEVICE_DRIVERS lists one branded entry per supported protocol", () => { assert.deepEqual( DEVICE_DRIVERS.map((driver) => driver.brand), - ["Kochi", "Wooting", "Wuque Studio", "Razer", "Logitech", "SteelSeries", "Keychron", "Arbiter Studio", "VIA"], + ["Kochi", "Wooting", "Wuque Studio", "Razer", "Logitech", "SteelSeries", "Keychron", "Arbiter Studio", "ATK", "VIA"], ); }); @@ -102,6 +104,14 @@ test("VIA matches any vendor's raw HID collection but yields to a brand driver", assert.ok(createSupportedClient(wooting) instanceof WootingHidClient); }); +test("an ATK keyboard goes to the ATK driver while another vendor's VIA board still gets VIA", () => { + const atk = fakeDevice({ vendorId: 0x373b, productId: 0x10b0, collections: [collection(0xff60, 0x61)] }); + assert.ok(createSupportedClient(atk) instanceof AtkKeyboardHidClient); + assert.equal(deviceBrand(createSupportedClient(atk)!), "ATK"); + const unlisted = fakeDevice({ vendorId: 0x373b, productId: 0x1176, collections: [collection(0xff60, 0x61)] }); + assert.ok(createSupportedClient(unlisted) instanceof ViaHidClient); +}); + test("createSupportedClient returns the matching driver client", () => { assert.ok(createSupportedClient(WOOTING_DEVICE()) instanceof WootingHidClient); assert.ok(createSupportedClient(WUQUE_DEVICE()) instanceof WuqueHidClient); @@ -224,6 +234,7 @@ test("SUPPORTED_HID_FILTERS concatenates the vendor filter sets with no duplicat ...STEELSERIES_HID_FILTERS, ...KEYCHRON_HID_FILTERS, ...ARBITER_HID_FILTERS, + ...ATK_HID_FILTERS, ...VIA_HID_FILTERS, ]); const seen = new Set(SUPPORTED_HID_FILTERS.map((f) => `${f.vendorId}:${f.productId}:${f.usagePage}:${f.usage}`)); diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 818eb1d..6a97876 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -6,6 +6,7 @@ import { LogitechKeyboardHidClient } from "./logitech/hid.ts"; import { SteelSeriesKeyboardHidClient } from "./steelseries/hid.ts"; import { KeychronHidClient } from "./keychron/hid.ts"; import { ArbiterKeyboardHidClient } from "./arbiter/hid.ts"; +import { AtkKeyboardHidClient } from "./atk/hid.ts"; import { ViaHidClient } from "./via/hid.ts"; export type SupportedClient = @@ -17,6 +18,7 @@ export type SupportedClient = | SteelSeriesKeyboardHidClient | KeychronHidClient | ArbiterKeyboardHidClient + | AtkKeyboardHidClient | ViaHidClient; export interface DeviceDriver { @@ -35,6 +37,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "SteelSeries", supports: (device) => SteelSeriesKeyboardHidClient.isSupported(device), create: (device) => new SteelSeriesKeyboardHidClient(device), score: () => 6 }, { brand: "Keychron", supports: (device) => KeychronHidClient.isSupported(device), create: (device) => new KeychronHidClient(device), score: () => 6 }, { brand: "Arbiter Studio", supports: (device) => ArbiterKeyboardHidClient.isSupported(device), create: (device) => new ArbiterKeyboardHidClient(device), score: () => 6 }, + { brand: "ATK", supports: (device) => AtkKeyboardHidClient.isSupported(device), create: (device) => new AtkKeyboardHidClient(device), score: () => 6 }, // Last: VIA matches any board with a raw-HID collection, so brand drivers win. { brand: "VIA", supports: (device) => ViaHidClient.isSupported(device), create: (device) => new ViaHidClient(device), score: () => 5 }, ]; diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index f0f9621..1be0a22 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -42,6 +42,7 @@ import { STEELSERIES_KEYBOARD_PRODUCT_IDS, STEELSERIES_VENDOR_ID, } from "@openmouse/keyboard-protocol/steelseries"; +import { ATK_CONFIG_USAGE, ATK_CONFIG_USAGE_PAGE, ATK_KEYBOARD_PRODUCT_IDS, ATK_VENDOR_ID } from "@openmouse/keyboard-protocol/atk"; import { VIA_USAGE, VIA_USAGE_PAGE } from "@openmouse/keyboard-protocol/via"; export const VENDOR_ID = { @@ -146,6 +147,18 @@ export const ARBITER_HID_FILTERS: HIDDeviceFilter[] = ARBITER_PRODUCTS.map((entr usage: ARBITER_CONFIG_USAGE, })); +/** + * ATK keyboards answer the VIA-framed raw-HID collection (0xFF60:0x61) with + * their own custom commands. Each catalogued product id is filtered + * explicitly so no other vendor's VIA board is claimed as an ATK keyboard. + */ +export const ATK_HID_FILTERS: HIDDeviceFilter[] = ATK_KEYBOARD_PRODUCT_IDS.map((productId) => ({ + vendorId: ATK_VENDOR_ID, + productId, + usagePage: ATK_CONFIG_USAGE_PAGE, + usage: ATK_CONFIG_USAGE, +})); + /** * VIA/QMK raw HID (0xFF60:0x61). Any vendor can ship VIA firmware, so this * filter has no vendor or product id; it matches the collection only. @@ -161,5 +174,6 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...STEELSERIES_HID_FILTERS, ...KEYCHRON_HID_FILTERS, ...ARBITER_HID_FILTERS, + ...ATK_HID_FILTERS, ...VIA_HID_FILTERS, ]; diff --git a/src/index.ts b/src/index.ts index 6e733fc..cc61948 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,5 @@ export * as logitech from "./logitech/index.js"; export * as steelseries from "./steelseries/index.js"; export * as keychron from "./keychron/index.js"; export * as arbiter from "./arbiter/index.js"; +export * as atk from "./atk/index.js"; export * as via from "./via/index.js";