diff --git a/package.json b/package.json index 4ba09eb..d4f5a04 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "types": "./dist/atk/index.d.ts", "import": "./dist/atk/index.js" }, + "./bitmouse": { + "types": "./dist/bitmouse/index.d.ts", + "import": "./dist/bitmouse/index.js" + }, "./endgame-gear-we": { "types": "./dist/endgame-gear/wireless.d.ts", "import": "./dist/endgame-gear/wireless.js" diff --git a/src/bitmouse/index.ts b/src/bitmouse/index.ts new file mode 100644 index 0000000..db2aed1 --- /dev/null +++ b/src/bitmouse/index.ts @@ -0,0 +1,523 @@ +/** + * Pure codecs for the "BITMOUSE" OEM protocol used by newer ATK and VXE mice + * (ATK ZERO, ATK A9 NK, VXE R1 S/R2 SE, …), unit-tested without WebHID. + * + * This is a different family from the 16-byte EEPROM protocol in atk/index.ts: + * frames are 63 bytes on report 0x08, carried on a vendor collection at usage + * page 0xff05 / usage 0x0001 rather than 0xff02 / 0x0002. + * + * Frame layout, request: + * + * [0] checksum sum of bytes 1..62, low byte + * [1] 0x72 constant command code + * [2] paramLen request length field (see the command table) + * [3] cmdSn sequence; replies observed to carry a constant 0x3a + * [4] target 0 = the device addressed directly, 1 = mouse behind a receiver + * [5] commandId + * [6] cmdLen expected reply payload length + * [7..] payload + * + * Replies arrive without the checksum byte, so every field sits one byte + * earlier: [0] 0x72, [1] status (0xff = error), [2] cmdSn, [3] target, + * [4] commandId, [5] payload length, [6..] payload. + * + * The device does not clear the tail of a reply, so bytes past the reported + * length are leftovers from the previous exchange and must be discarded — + * bitmouseDecodeReply trims to the reported length for this reason. + * + * Sources: the vendor's own WebHID configurator (ATK HUB v3.2.21, hub.atk.pro) + * for the framing, command ids and field offsets; the values were then read + * back from an ATK ZERO on both transports. Fields still unexplained are + * marked below rather than given a guessed meaning. + */ + +export const BITMOUSE_REPORT_ID = 0x08; +export const BITMOUSE_FRAME_LENGTH = 63; +export const BITMOUSE_COMMAND_CODE = 0x72; +export const BITMOUSE_PAYLOAD_OFFSET = 7; +/** Replies drop the checksum byte, so their payload starts one byte earlier. */ +export const BITMOUSE_REPLY_PAYLOAD_OFFSET = 6; +export const BITMOUSE_ERROR_STATUS = 0xff; + +/** Discovery: the config channel is the only 0xff05 collection on these mice. */ +export const BITMOUSE_USAGE_PAGE = 0xff05; +export const BITMOUSE_USAGE = 0x0001; + +/** Byte 4. A receiver relays to its mouse on target 1 and answers on target 0. */ +export const BITMOUSE_TARGET = { + device: 0x00, + mouseBehindReceiver: 0x01, +} as const; + +export const BITMOUSE_COMMAND = { + setReportRate: 1, + setDpi: 2, + setSilentHeight: 3, + setFarDistance: 27, + setSensorModel: 31, + getBatteryLevel: 7, + getCurrentMouseConfig: 9, + setLinearCorrection: 11, + setRippleControl: 12, + setMotionSync: 13, + getBatteryChargingStatus: 15, + setSensorSleepTime: 21, + setStabilizationTime: 22, + getAddressData: 23, + getDeviceType: 26, + getDeviceVersion: 28, + mouseCidMid: 74, + getDongleConnectStatus: 129, + getDongleVersion: 136, + getMouseCidMidDongle: 137, +} as const; + +/** + * Request lengths the vendor software sends for each command, as + * [paramLen, cmdLen]. The firmware rejects a mismatched pair, so these are + * copied rather than derived. + */ +export const BITMOUSE_LENGTHS = { + setReportRate: [3, 1], + setDpi: [12, 10], + setSilentHeight: [4, 2], + setFarDistance: [3, 1], + setSensorModel: [3, 1], + getBatteryLevel: [2, 1], + getCurrentMouseConfig: [19, 17], + setLinearCorrection: [3, 1], + setRippleControl: [3, 1], + setMotionSync: [3, 1], + getBatteryChargingStatus: [2, 1], + setSensorSleepTime: [5, 2], + setStabilizationTime: [3, 1], + getAddressData: [10, 13], + getDeviceType: [2, 1], + getDeviceVersion: [5, 3], + mouseCidMid: [8, 6], + getDongleConnectStatus: [3, 1], + getDongleVersion: [5, 3], + getMouseCidMidDongle: [8, 6], +} as const satisfies Record; + +export const BITMOUSE_POLLING_RATES: ReadonlyArray = [ + [0, 1000], + [1, 500], + [2, 250], + [3, 125], + [4, 8000], + [5, 4000], + [6, 2000], +]; + +/** Reported by getDeviceType; names are the vendor's own. */ +export const BITMOUSE_DEVICE_TYPES: ReadonlyArray = [ + [0, "dongle1K"], + [1, "dongle4K"], + [2, "wired1K"], + [3, "wired8K"], + [4, "dongle2K"], + [5, "dongle8K"], + [6, "wired2K"], + [7, "wired4K"], +]; + +export interface BitmouseRequest { + commandId: number; + paramLen: number; + cmdLen: number; + target?: number; + payload?: readonly number[]; +} + +export interface BitmouseReply { + commandId: number; + target: number; + status: number; + /** Constant 0x3a in every reply captured so far; meaning unknown. */ + cmdSn: number; + isError: boolean; + payload: Uint8Array; +} + +export function bitmouseChecksum(frame: Uint8Array | readonly number[]): number { + let sum = 0; + for (let index = 1; index < frame.length; index += 1) sum += frame[index]! & 0xff; + return sum & 0xff; +} + +export function bitmouseEncodeRequest(request: BitmouseRequest): Uint8Array { + const frame = new Uint8Array(BITMOUSE_FRAME_LENGTH); + frame[1] = BITMOUSE_COMMAND_CODE; + frame[2] = request.paramLen & 0xff; + frame[3] = 0; + frame[4] = (request.target ?? BITMOUSE_TARGET.device) & 0xff; + frame[5] = request.commandId & 0xff; + frame[6] = request.cmdLen & 0xff; + const payload = request.payload ?? []; + const room = BITMOUSE_FRAME_LENGTH - BITMOUSE_PAYLOAD_OFFSET; + if (payload.length > room) throw new Error(`A BITMOUSE payload holds at most ${room} bytes.`); + frame.set(payload.map((byte) => byte & 0xff), BITMOUSE_PAYLOAD_OFFSET); + frame[0] = bitmouseChecksum(frame); + return frame; +} + +/** Returns null for anything that is not a well-formed reply frame. */ +export function bitmouseDecodeReply(frame: Uint8Array): BitmouseReply | null { + if (frame.length < BITMOUSE_REPLY_PAYLOAD_OFFSET) return null; + if (frame[0] !== BITMOUSE_COMMAND_CODE) return null; + const reported = frame[5] ?? 0; + const available = frame.length - BITMOUSE_REPLY_PAYLOAD_OFFSET; + return { + commandId: frame[4]!, + target: frame[3]!, + status: frame[1]!, + cmdSn: frame[2]!, + isError: frame[1] === BITMOUSE_ERROR_STATUS, + // Trimmed: the tail of a reply still holds the previous exchange's bytes. + payload: frame.slice( + BITMOUSE_REPLY_PAYLOAD_OFFSET, + BITMOUSE_REPLY_PAYLOAD_OFFSET + Math.min(reported, available), + ), + }; +} + +export function bitmouseDecodePollingRate(code: number): number | null { + return BITMOUSE_POLLING_RATES.find(([encoded]) => encoded === code)?.[1] ?? null; +} + +export function bitmouseEncodePollingRate(hertz: number): number | null { + return BITMOUSE_POLLING_RATES.find(([, rate]) => rate === hertz)?.[0] ?? null; +} + +export function bitmouseDecodeDeviceType(code: number): string | null { + return BITMOUSE_DEVICE_TYPES.find(([encoded]) => encoded === code)?.[1] ?? null; +} + +/** + * Settings the config block does not carry. The vendor reads each with + * getAddressData at a fixed address, one byte at a time. + */ +export const BITMOUSE_ADDRESS = { + sensorModel: 74, + farDistance: 75, + sensorAngle: 2692, +} as const; + +/** + * Sensor sampling modes. The vendor exposes three of the firmware's six codes, + * labelled Base / Athletics / Athletics Max; they are mapped onto the shared + * eco-to-ultra names here. + */ +export const BITMOUSE_SENSOR_MODES: ReadonlyArray = [ + [0, "Eco"], + [4, "High"], + [5, "Ultra"], +]; + +export function bitmouseDecodeSensorMode(code: number): "Eco" | "High" | "Ultra" | null { + return BITMOUSE_SENSOR_MODES.find(([encoded]) => encoded === code)?.[1] ?? null; +} + +export function bitmouseEncodeSensorMode(name: string): number | null { + return BITMOUSE_SENSOR_MODES.find(([, label]) => label === name)?.[0] ?? null; +} + +/** + * Lift-off. The config block's own silentHeight byte reads zero and is not the + * height: the vendor reads the level as offsetCalibration + 1 and writes it as + * { height: 0, offsetCalibration: level - 1 }. + * + * The level uses the same register scale as the A9 family (see atk/index.ts): + * tenths of a millimetre offset by six, so code 1 is 0.7 mm and code 11 is + * 1.7 mm — the continuous range the vendor software presents as a slider. + */ +export const BITMOUSE_LIFT_OFF_MIN_CODE = 1; +export const BITMOUSE_LIFT_OFF_MAX_CODE = 11; + +export function bitmouseDecodeLiftOffLevel(offsetCalibration: number): number { + return offsetCalibration + 1; +} + +export function bitmouseLiftOffMillimetres(code: number): number | null { + return code ? (code + 6) / 10 : null; +} + +export function bitmouseLiftOffCode(millimetres: number): number { + return Math.round(millimetres * 10) - 6; +} + +export function bitmouseSetLiftOffRequest(code: number): BitmouseRequest { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.setSilentHeight; + if (!Number.isInteger(code) + || code < BITMOUSE_LIFT_OFF_MIN_CODE + || code > BITMOUSE_LIFT_OFF_MAX_CODE) { + throw new Error( + `A lift-off code runs ${BITMOUSE_LIFT_OFF_MIN_CODE} to ${BITMOUSE_LIFT_OFF_MAX_CODE}.`, + ); + } + return { + commandId: BITMOUSE_COMMAND.setSilentHeight, + paramLen, + cmdLen, + payload: [0, code - 1], + }; +} + +export function bitmouseSetFarDistanceRequest(enabled: boolean): BitmouseRequest { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.setFarDistance; + return { commandId: BITMOUSE_COMMAND.setFarDistance, paramLen, cmdLen, payload: [enabled ? 1 : 0] }; +} + +export function bitmouseSetSensorModeRequest(code: number): BitmouseRequest { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.setSensorModel; + return { commandId: BITMOUSE_COMMAND.setSensorModel, paramLen, cmdLen, payload: [code & 0xff] }; +} + +export interface BitmouseConfig { + profile: number; + configVersion: number; + pollingRateHz: number | null; + silentHeight: number; + offsetCalibration: number; + motionSync: boolean; + linearCorrection: boolean; + rippleControl: boolean; + sleepSeconds: number; + debounceMs: number; +} + +/** + * getCurrentMouseConfig payload. The vendor's own accessors overlap at offsets + * 3-4 (a 16-bit DPI value and an 8-bit lift-off byte claim the same ground) and + * an ATK ZERO reports zero there on both transports, so neither field is + * decoded as DPI here — DPI comes from the address block below instead. + */ +export function bitmouseDecodeConfig(payload: Uint8Array): BitmouseConfig | null { + if (payload.length < 12) return null; + return { + profile: payload[0]!, + configVersion: payload[1]!, + pollingRateHz: bitmouseDecodePollingRate(payload[2]!), + silentHeight: payload[4]!, + offsetCalibration: payload[5]!, + motionSync: payload[6] === 1, + linearCorrection: payload[7] === 1, + rippleControl: payload[8] === 1, + sleepSeconds: payload[9]! | (payload[10]! << 8), + debounceMs: payload[11]!, + }; +} + +/** The DPI table is read with getAddressData in 10-byte chunks from address 1. */ +export const BITMOUSE_DPI_BLOCK_ADDRESS = 1; +export const BITMOUSE_DPI_BLOCK_CHUNK = 10; +export const BITMOUSE_DPI_BLOCK_CHUNKS = 7; +export const BITMOUSE_DPI_STAGE_COUNT = 8; +export const BITMOUSE_DPI_STAGE_LENGTH = 8; +/** getAddressData echoes the address and length before the bytes it read. */ +export const BITMOUSE_ADDRESS_DATA_OFFSET = 3; + +export interface BitmouseDpiStage { + x: number; + y: number; + red: number; + green: number; + blue: number; + /** + * Byte 7 of the stored record. A write puts a literal 0 here, so it reads + * back as 0 on every stage; it is not the enable bit, which a write carries + * one byte further along and the table never stores. + */ + reserved: number; +} + +export interface BitmouseDpiBlock { + currentIndex: number; + stageCount: number; + stages: BitmouseDpiStage[]; +} + +export function bitmouseAddressDataRequest(address: number, length: number): BitmouseRequest { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.getAddressData; + return { + commandId: BITMOUSE_COMMAND.getAddressData, + paramLen, + cmdLen, + payload: [address & 0xff, (address >> 8) & 0xff, length & 0xff], + }; +} + +/** The addresses the vendor sweeps to assemble the DPI table. */ +export function bitmouseDpiBlockAddresses(): number[] { + return Array.from( + { length: BITMOUSE_DPI_BLOCK_CHUNKS }, + (_unused, index) => BITMOUSE_DPI_BLOCK_ADDRESS + index * BITMOUSE_DPI_BLOCK_CHUNK, + ); +} + +export function bitmouseDecodeDpiBlock(bytes: Uint8Array | readonly number[]): BitmouseDpiBlock | null { + const needed = 2 + BITMOUSE_DPI_STAGE_COUNT * BITMOUSE_DPI_STAGE_LENGTH; + if (bytes.length < needed) return null; + const stages: BitmouseDpiStage[] = []; + for (let index = 0; index < BITMOUSE_DPI_STAGE_COUNT; index += 1) { + const at = 2 + index * BITMOUSE_DPI_STAGE_LENGTH; + stages.push({ + x: bytes[at]! | (bytes[at + 1]! << 8), + y: bytes[at + 2]! | (bytes[at + 3]! << 8), + blue: bytes[at + 4]!, + green: bytes[at + 5]!, + red: bytes[at + 6]!, + reserved: bytes[at + 7]!, + }); + } + return { currentIndex: bytes[0]!, stageCount: bytes[1]!, stages }; +} + +export interface BitmouseProduct { + name: string; + /** True for a receiver, which relays configuration to the mouse on target 1. */ + receiver: boolean; + sensor: keyof typeof BITMOUSE_DPI_RANGES; + /** The cid,mid pair the mouse answers with, for confirming the model. */ + cidMid: string; +} + +/** + * Products confirmed on hardware. The vendor software drives many more models + * over this protocol; each one needs its own hardware check before it is added. + */ +export const BITMOUSE_PRODUCTS: ReadonlyMap = new Map([ + [0x1154, { name: "ATK ZERO", receiver: false, sensor: "PAW3950Ultra", cidMid: "1,1" }], + [0x1155, { name: "ATK ZERO", receiver: true, sensor: "PAW3950Ultra", cidMid: "1,1" }], +] as const); + +export const BITMOUSE_PRODUCT_IDS: readonly number[] = [...BITMOUSE_PRODUCTS.keys()]; + +export interface BitmouseDpiRange { + min: number; + max: number; + /** Ascending step segments: `step` applies up to but not including `until`. */ + segments: ReadonlyArray<{ until: number; step: number }>; +} + +/** Sensor DPI ranges, as the vendor configurator states them. */ +export const BITMOUSE_DPI_RANGES = { + PAW3950Ultra: { + min: 10, + max: 42000, + segments: [{ until: 10000, step: 10 }, { until: 30000, step: 50 }, { until: 42001, step: 100 }], + }, +} as const satisfies Record; + +export function bitmouseDpiOptions(range: BitmouseDpiRange): number[] { + const options: number[] = []; + let dpi = range.min; + for (const segment of range.segments) { + const ceiling = Math.min(segment.until, range.max + 1); + for (; dpi < ceiling; dpi += segment.step) options.push(dpi); + } + if (options[options.length - 1] !== range.max) options.push(range.max); + return options; +} + +/** + * The stages that actually hold a DPI value. + * + * Byte 1 of the block reads 8 on an ATK ZERO carrying only two configured + * stages, so it is the size of the table rather than a count of enabled + * entries. Unconfigured slots read 0 DPI (keeping stale colour bytes), so the + * usable list is the leading run of non-zero stages. + */ +export function bitmouseEnabledStages(block: BitmouseDpiBlock): BitmouseDpiStage[] { + const limit = Math.min(block.stageCount || block.stages.length, block.stages.length); + const usable: BitmouseDpiStage[] = []; + for (let index = 0; index < limit; index += 1) { + const stage = block.stages[index]!; + if (stage.x === 0) break; + usable.push(stage); + } + return usable; +} + +export interface BitmouseDpiWrite { + index: number; + x: number; + y: number; + red: number; + green: number; + blue: number; + /** + * Applies the stage as well as storing it. The vendor sets this exactly when + * the stage being written is the active one; writing a stage with it clear + * updates the table without moving the sensor onto that stage. + */ + enable: boolean; +} + +export function bitmouseSetDpiRequest(stage: BitmouseDpiWrite): BitmouseRequest { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.setDpi; + return { + commandId: BITMOUSE_COMMAND.setDpi, + paramLen, + cmdLen, + payload: [ + stage.index, + stage.x & 0xff, (stage.x >> 8) & 0xff, + stage.y & 0xff, (stage.y >> 8) & 0xff, + stage.blue, stage.green, stage.red, + 0, + stage.enable ? 1 : 0, + ], + }; +} + +/** Sleep is a little-endian second count in the sensor-sleep command. */ +export function bitmouseSetSleepRequest(seconds: number): BitmouseRequest { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.setSensorSleepTime; + return { + commandId: BITMOUSE_COMMAND.setSensorSleepTime, + paramLen, + cmdLen, + payload: [seconds & 0xff, (seconds >> 8) & 0xff], + }; +} + +/** Lift-off and its calibration byte share one command. */ +export function bitmouseSetSilentHeightRequest(height: number, offsetCalibration: number): BitmouseRequest { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.setSilentHeight; + return { + commandId: BITMOUSE_COMMAND.setSilentHeight, + paramLen, + cmdLen, + payload: [height & 0xff, offsetCalibration & 0xff], + }; +} + +/** A single-byte flag write: motion sync, ripple control, linear correction. */ +export function bitmouseSetFlagRequest( + commandId: number, + lengths: readonly [number, number], + value: number, +): BitmouseRequest { + return { commandId, paramLen: lengths[0], cmdLen: lengths[1], payload: [value & 0xff] }; +} + +export interface BitmouseCidMid { + cid: number; + mid: number; +} + +/** Identifies the model: an ATK ZERO answers cid 1, mid 1. */ +export function bitmouseDecodeCidMid(payload: Uint8Array): BitmouseCidMid | null { + if (payload.length < 6) return null; + return { + cid: payload[1]!, + mid: payload[2]! | (payload[3]! << 8) | (payload[4]! << 16) | (payload[5]! << 24), + }; +} + +export function bitmouseDecodeVersion(payload: Uint8Array): string | null { + if (payload.length < 3) return null; + return `${payload[0]}.${payload[1]}.${payload[2]}`; +} diff --git a/src/drivers/atk/bitmouse-hid.ts b/src/drivers/atk/bitmouse-hid.ts new file mode 100644 index 0000000..69e3176 --- /dev/null +++ b/src/drivers/atk/bitmouse-hid.ts @@ -0,0 +1,610 @@ +import { + bitmouseAddressDataRequest, + bitmouseDecodeCidMid, + bitmouseDecodeConfig, + bitmouseDecodeDpiBlock, + bitmouseDecodeReply, + bitmouseDecodeSensorMode, + bitmouseDecodeLiftOffLevel, + bitmouseLiftOffMillimetres, + bitmouseDecodeVersion, + bitmouseDpiBlockAddresses, + bitmouseDpiOptions, + bitmouseEnabledStages, + bitmouseEncodePollingRate, + bitmouseEncodeSensorMode, + bitmouseEncodeRequest, + bitmouseSetDpiRequest, + bitmouseSetFarDistanceRequest, + bitmouseSetFlagRequest, + bitmouseSetLiftOffRequest, + bitmouseSetSensorModeRequest, + bitmouseSetSleepRequest, + BITMOUSE_ADDRESS, + BITMOUSE_ADDRESS_DATA_OFFSET, + BITMOUSE_COMMAND, + BITMOUSE_DPI_BLOCK_CHUNK, + BITMOUSE_DPI_RANGES, + BITMOUSE_LENGTHS, + BITMOUSE_LIFT_OFF_MAX_CODE, + BITMOUSE_LIFT_OFF_MIN_CODE, + BITMOUSE_POLLING_RATES, + BITMOUSE_PRODUCTS, + BITMOUSE_REPORT_ID, + BITMOUSE_TARGET, + BITMOUSE_USAGE, + BITMOUSE_USAGE_PAGE, + type BitmouseConfig, + type BitmouseDpiBlock, + type BitmouseProduct, + type BitmouseReply, + type BitmouseRequest, +} from "@openmouse/protocol/bitmouse"; +import type { MouseStatus } from "../mouse-types.ts"; +import { VENDOR_ID } from "../vendors.ts"; + +/** + * Driver for ATK's BITMOUSE configuration channel — the protocol the current + * ATK HUB speaks to the ZERO family. See bitmouse/index.ts for the framing. + * + * The older ATK mice in atk/hid.ts use a different channel entirely (16-byte + * EEPROM commands on usage page 0xff02), so the two drivers never contend for + * the same collection. + * + * Only the products verified on hardware are claimed here. The vendor software + * drives many more models over this same protocol; adding one is a product-table + * entry plus a hardware check. + */ + +const REPLY_TIMEOUT_MS = 700; +const WRITE_SETTLE_MS = 120; + +/** + * Debounce is written as a raw byte with no documented ceiling; 15 ms matches + * the rest of the ATK range. Only 4 and 8 ms have been exercised on hardware. + */ +const DEBOUNCE_MAX_MS = 15; +const SLEEP_SECONDS: readonly number[] = [30, 60, 120, 300, 600, 1800]; +const SLEEP_MIN_SECONDS = 30; +const SLEEP_MAX_SECONDS = 0xffff; + +type LiftOffDistance = NonNullable; + +/** + * The register runs 1 to 11 (0.7 mm to 1.7 mm in tenths), which the vendor + * software exposes as a slider. MouseStatus carries three stops, so the shared + * control writes the ends and the middle and reports anything the vendor app + * left in between by which stop it is nearest. + */ +const LIFT_OFF_LEVELS: ReadonlyArray = [ + [1, "Low"], + [4, "Medium"], + [11, "High"], +]; + +export class AtkBitmouseHidClient { + readonly canDisableSleep = false; + readonly device: HIDDevice; + + private queue: Promise = Promise.resolve(); + private lastStatus: MouseStatus | null = null; + private dpiBlock: BitmouseDpiBlock | null = null; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== VENDOR_ID.atk) return false; + if (!BITMOUSE_PRODUCTS.has(device.productId)) return false; + return device.collections.some((collection) => hasConfigChannel(collection)); + } + + private get product(): BitmouseProduct | null { + return BITMOUSE_PRODUCTS.get(this.device.productId) ?? null; + } + + /** A receiver relays configuration to the mouse on target 1. */ + private get target(): number { + return this.product?.receiver ? BITMOUSE_TARGET.mouseBehindReceiver : BITMOUSE_TARGET.device; + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + this.lastStatus = null; + this.dpiBlock = null; + if (this.device.opened) await this.device.close(); + } + + /** The protocol has a change-notification report, but it is not decoded yet. */ + async startNotifications(): Promise { + return false; + } + + displayName(): string { + return this.product?.name ?? this.device.productName?.trim() ?? "ATK"; + } + + isWireless(): boolean { + return this.product?.receiver ?? false; + } + + maxDpi(): number { + return BITMOUSE_DPI_RANGES[this.product?.sensor ?? "PAW3950Ultra"].max; + } + + getSleepOptions(): readonly number[] { + return SLEEP_SECONDS; + } + + getDebounceMaxMs(): number { + return DEBOUNCE_MAX_MS; + } + + getSupportedPollingRates(): number[] { + return BITMOUSE_POLLING_RATES.map(([, hertz]) => hertz).sort((left, right) => left - right); + } + + getDpiOptions(): number[] { + return bitmouseDpiOptions(BITMOUSE_DPI_RANGES[this.product?.sensor ?? "PAW3950Ultra"]); + } + + async readStatus(live = false): Promise { + await this.open(); + const config = await this.readConfig(); + const battery = await this.readByte(BITMOUSE_COMMAND.getBatteryLevel, BITMOUSE_LENGTHS.getBatteryLevel); + const charging = await this.readByte( + BITMOUSE_COMMAND.getBatteryChargingStatus, + BITMOUSE_LENGTHS.getBatteryChargingStatus, + ); + + // The DPI table costs seven exchanges, so a live refresh reuses the last one. + if (!live || !this.dpiBlock) this.dpiBlock = await this.readDpiBlock(); + const dpi = this.activeStage(); + + if (live && this.lastStatus) { + return this.lastStatus = { + ...this.lastStatus, + batteryPercent: battery, + batteryState: charging === 1 ? "Charging" : "Discharging", + pollingRateHz: config?.pollingRateHz ?? this.lastStatus.pollingRateHz, + dpi: dpi ?? this.lastStatus.dpi, + }; + } + + const sensorMode = bitmouseDecodeSensorMode( + await this.readAddressByte(BITMOUSE_ADDRESS.sensorModel) ?? -1, + ); + const longRangeByte = await this.readAddressByte(BITMOUSE_ADDRESS.farDistance); + const longRange = longRangeByte === null ? null : longRangeByte === 1; + + const usable = this.dpiBlock ? bitmouseEnabledStages(this.dpiBlock) : []; + const stages = usable.map((stage) => stage.x); + const activeStage = this.dpiBlock && this.dpiBlock.currentIndex < usable.length + ? this.dpiBlock.currentIndex + : undefined; + return this.lastStatus = { + brand: "ATK", + name: this.displayName(), + ui: { + family: "atk-bitmouse", + hideUnsupportedPollingRates: true, + showAdvancedSection: true, + forceShowBattery: battery !== null, + dpiStageEditor: stages.length + ? { + maxStages: usable.length, + countEditable: false, + minDpi: BITMOUSE_DPI_RANGES[this.product?.sensor ?? "PAW3950Ultra"].min, + maxDpi: this.maxDpi(), + stepDpi: 10, + } + : undefined, + }, + batteryPercent: battery, + batteryState: charging === 1 ? "Charging" : "Discharging", + dpi: dpi ?? 0, + supportsSeparateDpiAxes: false, + dpiStages: stages.length ? stages : undefined, + activeDpiStage: activeStage, + pollingRateHz: config?.pollingRateHz ?? 0, + supportedPollingRates: this.getSupportedPollingRates(), + activeProfile: config ? config.profile : null, + connectionType: this.isWireless() ? "Wireless" : "Wired", + connectionDetail: this.isWireless() ? "2.4 GHz receiver" : "Wired USB", + motionSync: config?.motionSync ?? null, + rippleControl: config?.rippleControl ?? null, + debounceMs: config?.debounceMs ?? null, + sleepTimeout: config?.sleepSeconds || null, + // The vendor names this "straight line correction"; it is angle snapping. + angleSnapping: config?.linearCorrection ?? null, + sensorMode, + sensorModeEditable: sensorMode !== null, + longRangeMode: longRange, + liftOffDistance: this.decodeLiftOff(config), + supportedLiftOffDistances: ["Low", "Medium", "High"], + liftOffScale: this.liftOffScale(config), + firmware: await this.readFirmware(), + }; + } + + async setPollingRate(pollingRateHz: number): Promise { + const code = bitmouseEncodePollingRate(pollingRateHz); + if (code === null) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); + await this.write(bitmouseSetFlagRequest( + BITMOUSE_COMMAND.setReportRate, + BITMOUSE_LENGTHS.setReportRate, + code, + )); + const confirmed = (await this.readConfig())?.pollingRateHz; + if (confirmed !== pollingRateHz) { + throw new Error(`The mouse kept ${confirmed ?? "an unknown rate"} instead of ${pollingRateHz} Hz.`); + } + this.patch({ pollingRateHz }); + return pollingRateHz; + } + + /** Writes the stage the mouse is currently on. */ + async setDpi(dpi: number): Promise { + if (!this.dpiBlock) this.dpiBlock = await this.readDpiBlock(); + return await this.writeStage(this.dpiBlock?.currentIndex ?? 0, dpi); + } + + /** Writes any stage, which is what the shared DPI stage editor drives. */ + async setDpiStageValue(stage: number, dpi: number): Promise { + return await this.writeStage(stage, dpi); + } + + private async writeStage(index: number, dpi: number): Promise { + const range = BITMOUSE_DPI_RANGES[this.product?.sensor ?? "PAW3950Ultra"]; + if (!Number.isInteger(dpi) || dpi < range.min || dpi > range.max) { + throw new Error(`${dpi.toLocaleString()} is not a supported DPI value.`); + } + if (!this.dpiBlock) this.dpiBlock = await this.readDpiBlock(); + const stage = this.dpiBlock?.stages[index]; + if (!stage) throw new Error("The mouse did not report its DPI stages."); + + // Colour rides along with every DPI write, so it is carried over from the + // stage as read. `enable` is not part of the stored record: it tells the + // mouse to move onto this stage, which is only right for the active one. + const active = this.dpiBlock?.currentIndex ?? 0; + await this.write(bitmouseSetDpiRequest({ + index, + x: dpi, + y: dpi, + red: stage.red, + green: stage.green, + blue: stage.blue, + enable: index === active, + })); + this.dpiBlock = await this.readDpiBlock(); + const confirmed = this.dpiBlock?.stages[index]?.x; + if (confirmed !== dpi) { + throw new Error(`The mouse kept ${confirmed?.toLocaleString() ?? "an unknown value"} instead of ${dpi.toLocaleString()} DPI.`); + } + const stages = this.dpiBlock ? bitmouseEnabledStages(this.dpiBlock).map((entry) => entry.x) : undefined; + this.patch({ + dpiStages: stages?.length ? stages : undefined, + ...(index === active ? { dpi } : {}), + }); + return dpi; + } + + /** + * Lift-off. The level lives in the config block's offsetCalibration byte, + * not in the byte the vendor's field map calls silentHeight, and a write + * sends it as { height: 0, offsetCalibration: level - 1 }. + */ + async setLiftOffDistance(value: LiftOffDistance): Promise { + const level = LIFT_OFF_LEVELS.find(([, name]) => name === value)?.[0]; + if (level === undefined) { + throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); + } + await this.write(bitmouseSetLiftOffRequest(level)); + const confirmed = this.decodeLiftOff(await this.readConfig()); + if (confirmed !== value) { + throw new Error(`The mouse kept a ${String(confirmed ?? "unknown").toLowerCase()} lift-off distance instead of ${value.toLowerCase()}.`); + } + this.patch({ liftOffDistance: confirmed }); + return confirmed; + } + + /** + * The full 0.7-1.7 mm range, which is what the vendor software offers as a + * slider. The three-stop setLiftOffDistance above stays for the shared + * Low/Medium/High control. + */ + async setLiftOffScale(code: number): Promise { + await this.write(bitmouseSetLiftOffRequest(code)); + const config = await this.readConfig(); + const confirmed = config ? bitmouseDecodeLiftOffLevel(config.offsetCalibration) : null; + if (confirmed !== code) { + throw new Error(`The mouse kept lift-off code ${confirmed ?? "unknown"} instead of ${code}.`); + } + this.patch({ + liftOffDistance: this.decodeLiftOff(config), + liftOffScale: this.liftOffScale(config), + }); + return code; + } + + private liftOffScale(config: BitmouseConfig | null): MouseStatus["liftOffScale"] { + if (!config) return null; + const code = bitmouseDecodeLiftOffLevel(config.offsetCalibration); + const millimetres = bitmouseLiftOffMillimetres(code); + if (millimetres === null) return null; + return { + value: code, + min: BITMOUSE_LIFT_OFF_MIN_CODE, + max: BITMOUSE_LIFT_OFF_MAX_CODE, + millimetres, + minMillimetres: bitmouseLiftOffMillimetres(BITMOUSE_LIFT_OFF_MIN_CODE)!, + maxMillimetres: bitmouseLiftOffMillimetres(BITMOUSE_LIFT_OFF_MAX_CODE)!, + }; + } + + /** The vendor calls this "straight line correction". */ + async setAngleSnapping(enabled: boolean): Promise { + await this.write(bitmouseSetFlagRequest( + BITMOUSE_COMMAND.setLinearCorrection, + BITMOUSE_LENGTHS.setLinearCorrection, + enabled ? 1 : 0, + )); + const confirmed = (await this.readConfig())?.linearCorrection; + if (confirmed !== enabled) throw new Error(`The mouse left angle snapping ${confirmed ? "on" : "off"}.`); + this.patch({ angleSnapping: confirmed }); + return confirmed; + } + + /** ATK's "Ultra Long Range": more radio range for less battery life. */ + async setLongRangeMode(enabled: boolean): Promise { + await this.write(bitmouseSetFarDistanceRequest(enabled)); + const byte = await this.readAddressByte(BITMOUSE_ADDRESS.farDistance); + const confirmed = byte === null ? null : byte === 1; + if (confirmed !== enabled) { + throw new Error(`The mouse left long-range mode ${confirmed ? "on" : "off"}.`); + } + this.patch({ longRangeMode: confirmed }); + return confirmed; + } + + async setSensorMode(mode: "Eco" | "High" | "Ultra"): Promise<"Eco" | "High" | "Ultra"> { + const code = bitmouseEncodeSensorMode(mode); + if (code === null) throw new Error(`This mouse does not support a ${mode} sensor mode.`); + await this.write(bitmouseSetSensorModeRequest(code)); + const confirmed = bitmouseDecodeSensorMode( + await this.readAddressByte(BITMOUSE_ADDRESS.sensorModel) ?? -1, + ); + if (confirmed !== mode) { + throw new Error(`The mouse kept the ${confirmed ?? "unknown"} sensor mode instead of ${mode}.`); + } + this.patch({ sensorMode: confirmed }); + return confirmed; + } + + private decodeLiftOff(config: BitmouseConfig | null): LiftOffDistance | null { + if (!config) return null; + const millimetres = bitmouseLiftOffMillimetres(bitmouseDecodeLiftOffLevel(config.offsetCalibration)); + if (millimetres === null) return null; + if (millimetres < 1) return "Low"; + return millimetres < 1.5 ? "Medium" : "High"; + } + + /** One-byte reads for the settings the config block does not carry. */ + private async readAddressByte(address: number): Promise { + const reply = await this.exchange(bitmouseAddressDataRequest(address, 1)).catch(() => null); + return reply?.payload[BITMOUSE_ADDRESS_DATA_OFFSET] ?? null; + } + + async setMotionSync(enabled: boolean): Promise { + return await this.setFlag( + BITMOUSE_COMMAND.setMotionSync, BITMOUSE_LENGTHS.setMotionSync, + enabled, "motionSync", "Motion Sync", + ); + } + + async setRippleControl(enabled: boolean): Promise { + return await this.setFlag( + BITMOUSE_COMMAND.setRippleControl, BITMOUSE_LENGTHS.setRippleControl, + enabled, "rippleControl", "ripple control", + ); + } + + async setDebounceTime(milliseconds: number): Promise { + if (!Number.isInteger(milliseconds) || milliseconds < 0 || milliseconds > DEBOUNCE_MAX_MS) { + throw new Error(`Debounce must be a whole number of milliseconds between 0 and ${DEBOUNCE_MAX_MS}.`); + } + await this.write(bitmouseSetFlagRequest( + BITMOUSE_COMMAND.setStabilizationTime, + BITMOUSE_LENGTHS.setStabilizationTime, + milliseconds, + )); + const confirmed = (await this.readConfig())?.debounceMs; + if (confirmed !== milliseconds) { + throw new Error(`The mouse kept ${confirmed ?? "an unknown value"} ms of debounce instead of ${milliseconds} ms.`); + } + this.patch({ debounceMs: milliseconds }); + return milliseconds; + } + + async setSleepTimeout(seconds: number): Promise { + if (!Number.isInteger(seconds) || seconds < SLEEP_MIN_SECONDS || seconds > SLEEP_MAX_SECONDS) { + throw new Error(`The sleep timeout must be between ${SLEEP_MIN_SECONDS} and ${SLEEP_MAX_SECONDS} seconds.`); + } + await this.write(bitmouseSetSleepRequest(seconds)); + const confirmed = (await this.readConfig())?.sleepSeconds; + if (confirmed !== seconds) { + throw new Error(`The mouse kept a ${confirmed ?? "unknown"} second sleep timeout instead of ${seconds} seconds.`); + } + this.patch({ sleepTimeout: seconds }); + return seconds; + } + + private async setFlag( + commandId: number, + lengths: readonly [number, number], + enabled: boolean, + field: "motionSync" | "rippleControl", + label: string, + ): Promise { + await this.write(bitmouseSetFlagRequest(commandId, lengths, enabled ? 1 : 0)); + const config = await this.readConfig(); + const confirmed = config ? config[field] : null; + if (confirmed !== enabled) throw new Error(`The mouse left ${label} ${confirmed ? "on" : "off"}.`); + this.patch({ [field]: confirmed }); + return confirmed; + } + + private activeStage(): number | null { + const block = this.dpiBlock; + if (!block) return null; + return block.stages[block.currentIndex]?.x ?? null; + } + + private async readConfig(): Promise { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.getCurrentMouseConfig; + const reply = await this.exchange({ + commandId: BITMOUSE_COMMAND.getCurrentMouseConfig, + paramLen, + cmdLen, + }).catch(() => null); + return reply ? bitmouseDecodeConfig(reply.payload) : null; + } + + private async readByte(commandId: number, lengths: readonly [number, number]): Promise { + const reply = await this.exchange({ commandId, paramLen: lengths[0], cmdLen: lengths[1] }) + .catch(() => null); + return reply && reply.payload.length ? reply.payload[0]! : null; + } + + /** Seven ten-byte reads assemble the stage table; a gap makes it unusable. */ + private async readDpiBlock(): Promise { + const bytes: number[] = []; + for (const address of bitmouseDpiBlockAddresses()) { + const reply = await this.exchange(bitmouseAddressDataRequest(address, BITMOUSE_DPI_BLOCK_CHUNK)) + .catch(() => null); + if (!reply) return null; + const chunk = reply.payload.subarray( + BITMOUSE_ADDRESS_DATA_OFFSET, + BITMOUSE_ADDRESS_DATA_OFFSET + BITMOUSE_DPI_BLOCK_CHUNK, + ); + if (chunk.length < BITMOUSE_DPI_BLOCK_CHUNK) return null; + bytes.push(...chunk); + } + return bitmouseDecodeDpiBlock(bytes); + } + + private async readFirmware(): Promise { + const lines: string[] = []; + const mouse = await this.readVersion(BITMOUSE_COMMAND.getDeviceVersion, this.target); + if (mouse) lines.push(`Mouse ${mouse}`); + if (this.product?.receiver) { + // Dongle commands answer on target 0 even when the mouse is behind it. + const dongle = await this.readVersion(BITMOUSE_COMMAND.getDongleVersion, BITMOUSE_TARGET.device); + if (dongle) lines.push(`Dongle ${dongle}`); + } + return lines; + } + + private async readVersion(commandId: number, target: number): Promise { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.getDeviceVersion; + const reply = await this.exchange({ commandId, paramLen, cmdLen, target }).catch(() => null); + return reply ? bitmouseDecodeVersion(reply.payload) : null; + } + + /** + * The cid,mid pair the mouse answers with — an ATK ZERO reports 1,1. It is + * how the vendor software tells models apart behind a shared receiver PID, + * so it is the check to extend when adding a product. + */ + async readCidMid(): Promise { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.mouseCidMid; + const reply = await this.exchange({ + commandId: BITMOUSE_COMMAND.mouseCidMid, + paramLen, + cmdLen, + payload: [0], + }).catch(() => null); + const identity = reply ? bitmouseDecodeCidMid(reply.payload) : null; + return identity ? `${identity.cid},${identity.mid}` : null; + } + + /** True when the mouse identifies as the product its USB id claims. */ + async confirmsProduct(): Promise { + const expected = this.product?.cidMid; + if (!expected) return null; + const actual = await this.readCidMid(); + return actual === null ? null : actual === expected; + } + + private async write(request: BitmouseRequest): Promise { + await this.run(async () => { + await this.open(); + const frame = bitmouseEncodeRequest({ ...request, target: request.target ?? this.target }); + await this.device.sendReport(BITMOUSE_REPORT_ID, frame); + await delay(WRITE_SETTLE_MS); + }); + } + + /** Send a request and resolve with the reply carrying the same command id. */ + private async exchange(request: BitmouseRequest): Promise { + const frame = bitmouseEncodeRequest({ ...request, target: request.target ?? this.target }); + return await this.run(async () => { + await this.open(); + return await new Promise((resolve, reject) => { + const finish = () => { + clearTimeout(timer); + this.device.removeEventListener("inputreport", listener); + }; + const timer = setTimeout(() => { + finish(); + reject(new Error("The mouse did not answer — it may be asleep or out of range.")); + }, REPLY_TIMEOUT_MS); + const listener = (event: HIDInputReportEvent) => { + if (event.reportId !== BITMOUSE_REPORT_ID) return; + const decoded = bitmouseDecodeReply(copyDataView(event.data)); + if (!decoded || decoded.commandId !== request.commandId) return; + finish(); + if (decoded.isError) { + reject(new Error(`The mouse rejected command ${request.commandId}.`)); + return; + } + resolve(decoded); + }; + this.device.addEventListener("inputreport", listener); + this.device.sendReport(BITMOUSE_REPORT_ID, frame).catch((error: unknown) => { + finish(); + reject(error); + }); + }); + }); + } + + private patch(changes: Partial): void { + if (this.lastStatus) this.lastStatus = { ...this.lastStatus, ...changes }; + } + + private async run(task: () => Promise): Promise { + const started = this.queue.then(task, task); + this.queue = started.catch(() => undefined); + return await started; + } +} + +function hasConfigChannel(collection: HIDCollectionInfo): boolean { + const here = collection.usagePage === BITMOUSE_USAGE_PAGE + && collection.usage === BITMOUSE_USAGE + && collection.outputReports.some((report) => report.reportId === BITMOUSE_REPORT_ID); + return here || collection.children.some(hasConfigChannel); +} + +function copyDataView(view: DataView): Uint8Array { + return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/src/drivers/atk/bitmouse-protocol.test.ts b/src/drivers/atk/bitmouse-protocol.test.ts new file mode 100644 index 0000000..4bd12d7 --- /dev/null +++ b/src/drivers/atk/bitmouse-protocol.test.ts @@ -0,0 +1,325 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bitmouseAddressDataRequest, + bitmouseChecksum, + bitmouseDecodeCidMid, + bitmouseDecodeConfig, + bitmouseDecodeDeviceType, + bitmouseDecodeDpiBlock, + bitmouseDecodePollingRate, + bitmouseDecodeReply, + bitmouseDecodeVersion, + bitmouseDpiBlockAddresses, + bitmouseDecodeLiftOffLevel, + bitmouseLiftOffCode, + bitmouseLiftOffMillimetres, + bitmouseDecodeSensorMode, + bitmouseDpiOptions, + bitmouseEnabledStages, + bitmouseEncodePollingRate, + bitmouseEncodeRequest, + bitmouseEncodeSensorMode, + bitmouseSetDpiRequest, + bitmouseSetFarDistanceRequest, + bitmouseSetLiftOffRequest, + bitmouseSetSensorModeRequest, + bitmouseSetSleepRequest, + BITMOUSE_COMMAND, + BITMOUSE_COMMAND_CODE, + BITMOUSE_ADDRESS, + BITMOUSE_DPI_RANGES, + BITMOUSE_FRAME_LENGTH, + BITMOUSE_LENGTHS, + BITMOUSE_TARGET, +} from "@openmouse/protocol/bitmouse"; + +/** Pads a captured reply prefix out to the 63 bytes the device really sends. */ +function reply(...bytes: number[]): Uint8Array { + const frame = new Uint8Array(BITMOUSE_FRAME_LENGTH); + frame.set(bytes); + return frame; +} + +test("a request carries the command code, target and a trailing-sum checksum", () => { + const [paramLen, cmdLen] = BITMOUSE_LENGTHS.getDeviceVersion; + const frame = bitmouseEncodeRequest({ + commandId: BITMOUSE_COMMAND.getDeviceVersion, + paramLen, + cmdLen, + target: BITMOUSE_TARGET.mouseBehindReceiver, + }); + + assert.equal(frame.length, BITMOUSE_FRAME_LENGTH); + assert.equal(frame[1], BITMOUSE_COMMAND_CODE); + assert.equal(frame[2], 5); + assert.equal(frame[4], BITMOUSE_TARGET.mouseBehindReceiver); + assert.equal(frame[5], BITMOUSE_COMMAND.getDeviceVersion); + assert.equal(frame[6], 3); + assert.equal(frame[0], bitmouseChecksum(frame)); +}); + +test("requests address the device directly unless a target is given", () => { + const frame = bitmouseEncodeRequest({ commandId: BITMOUSE_COMMAND.getBatteryLevel, paramLen: 2, cmdLen: 1 }); + + assert.equal(frame[4], BITMOUSE_TARGET.device); +}); + +test("a payload longer than the frame is refused rather than truncated", () => { + assert.throws(() => bitmouseEncodeRequest({ + commandId: BITMOUSE_COMMAND.setDpi, + paramLen: 12, + cmdLen: 10, + payload: new Array(57).fill(0), + }), /at most 56 bytes/); +}); + +/** + * Captured from an ATK ZERO over its receiver. Everything past the reported + * length is the previous exchange's payload, still sitting in the buffer. + */ +test("a reply is trimmed to the length the device reports", () => { + const frame = reply(0x72, 0x00, 0x3a, 0x00, 0x1a, 0x01, + 0x03, 0xff, 0xff, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x08, 0x07, 0x04); + const decoded = bitmouseDecodeReply(frame); + + assert.ok(decoded); + assert.equal(decoded.commandId, BITMOUSE_COMMAND.getDeviceType); + assert.equal(decoded.isError, false); + assert.deepEqual([...decoded.payload], [0x03]); + assert.equal(bitmouseDecodeDeviceType(decoded.payload[0]!), "wired8K"); +}); + +test("replies that are truncated or not BITMOUSE frames are rejected", () => { + assert.equal(bitmouseDecodeReply(new Uint8Array([0x72, 0x00])), null); + assert.equal(bitmouseDecodeReply(reply(0x55, 0x00, 0x3a, 0x00, 0x1c, 0x03)), null); +}); + +test("a reply claiming more bytes than it carries is clamped", () => { + const decoded = bitmouseDecodeReply(new Uint8Array([0x72, 0x00, 0x3a, 0x00, 0x1c, 0x40, 0x03, 0x00])); + + assert.ok(decoded); + assert.deepEqual([...decoded.payload], [0x03, 0x00]); +}); + +test("an error status is surfaced", () => { + const decoded = bitmouseDecodeReply(reply(0x72, 0xff, 0x3a, 0x00, 0x1c, 0x00)); + + assert.ok(decoded); + assert.equal(decoded.isError, true); +}); + +test("polling-rate codes round trip", () => { + for (const hertz of [125, 250, 500, 1000, 2000, 4000, 8000]) { + assert.equal(bitmouseDecodePollingRate(bitmouseEncodePollingRate(hertz)!), hertz); + } + assert.equal(bitmouseEncodePollingRate(3000), null); + assert.equal(bitmouseDecodePollingRate(0x7f), null); +}); + +/** Captured with the mouse on 2000 Hz, motion sync on, 30 min sleep, 4 ms debounce. */ +test("the config block decodes a captured ATK ZERO reply", () => { + const frame = reply(0x72, 0x00, 0x3a, 0x00, 0x09, 0x11, + 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x08, 0x07, 0x04, + 0x00, 0x00, 0x00, 0x00, 0x00); + const decoded = bitmouseDecodeReply(frame); + const config = bitmouseDecodeConfig(decoded!.payload); + + assert.deepEqual(config, { + profile: 0, + configVersion: 0, + pollingRateHz: 2000, + silentHeight: 0, + offsetCalibration: 0, + motionSync: true, + linearCorrection: true, + rippleControl: true, + sleepSeconds: 1800, + debounceMs: 4, + }); +}); + +test("a config payload shorter than the fields it needs decodes to null", () => { + assert.equal(bitmouseDecodeConfig(new Uint8Array(11)), null); +}); + +test("CID/MID identifies the model", () => { + const decoded = bitmouseDecodeReply(reply(0x72, 0x00, 0x3a, 0x00, 0x4a, 0x06, + 0x00, 0x01, 0x01, 0x00, 0x00, 0x00)); + + assert.deepEqual(bitmouseDecodeCidMid(decoded!.payload), { cid: 1, mid: 1 }); + assert.equal(bitmouseDecodeCidMid(new Uint8Array(5)), null); +}); + +test("versions decode from the captured mouse and dongle replies", () => { + const mouse = bitmouseDecodeReply(reply(0x72, 0x00, 0x3a, 0x00, 0x1c, 0x03, 0x03, 0x00, 0x03, 0xff)); + const dongle = bitmouseDecodeReply(reply(0x72, 0x00, 0x3a, 0x00, 0x88, 0x03, 0x03, 0x00, 0x02, 0x43)); + + assert.equal(bitmouseDecodeVersion(mouse!.payload), "3.0.3"); + assert.equal(bitmouseDecodeVersion(dongle!.payload), "3.0.2"); + assert.equal(bitmouseDecodeVersion(new Uint8Array(2)), null); +}); + +test("the DPI table is swept in ten-byte chunks from address one", () => { + assert.deepEqual(bitmouseDpiBlockAddresses(), [1, 11, 21, 31, 41, 51, 61]); + + const request = bitmouseAddressDataRequest(11, 10); + const frame = bitmouseEncodeRequest(request); + + assert.equal(frame[5], BITMOUSE_COMMAND.getAddressData); + assert.deepEqual([...frame.slice(7, 10)], [11, 0, 10]); +}); + +test("DPI stages decode little-endian with the colour stored blue first", () => { + const block = new Uint8Array(2 + 8 * 8); + block[0] = 1; + block[1] = 4; + block.set([0x40, 0x06, 0x40, 0x06, 0x11, 0x22, 0x33, 0x01], 2); // stage 0: 1600 + block.set([0x20, 0x03, 0xe8, 0x03, 0x00, 0x00, 0x00, 0x00], 2 + 8); // stage 1: 800 x 1000 + + const decoded = bitmouseDecodeDpiBlock(block); + + assert.equal(decoded!.currentIndex, 1); + assert.equal(decoded!.stageCount, 4); + assert.deepEqual(decoded!.stages[0], { x: 1600, y: 1600, blue: 0x11, green: 0x22, red: 0x33, reserved: 1 }); + assert.deepEqual(decoded!.stages[1], { x: 800, y: 1000, blue: 0, green: 0, red: 0, reserved: 0 }); + assert.equal(bitmouseDecodeDpiBlock(new Uint8Array(20)), null); +}); + +test("a DPI write mirrors the layout the table is read back in", () => { + const frame = bitmouseEncodeRequest(bitmouseSetDpiRequest({ + index: 2, x: 1600, y: 800, red: 0x33, green: 0x22, blue: 0x11, enable: true, + })); + + assert.equal(frame[5], BITMOUSE_COMMAND.setDpi); + assert.deepEqual([...frame.slice(7, 17)], [2, 0x40, 0x06, 0x20, 0x03, 0x11, 0x22, 0x33, 0x00, 0x01]); +}); + +/** + * Captured from an ATK ZERO holding two configured stages. Byte 1 still reads + * 8, so the count field cannot be trusted to mean "enabled". + */ +test("only the stages carrying a DPI value are treated as enabled", () => { + const captured = [ + 0x00, 0x08, + 0x20, 0x03, 0x20, 0x03, 0x00, 0x00, 0xff, 0x00, + 0x90, 0x01, 0x90, 0x01, 0x00, 0x00, 0xff, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x90, 0x01, 0x90, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0xff, 0x00, + ...new Array(32).fill(0), + ]; + const block = bitmouseDecodeDpiBlock(captured); + + assert.equal(block!.currentIndex, 0); + assert.equal(block!.stageCount, 8); + assert.equal(block!.stages[0]!.x, 800); + assert.equal(block!.stages[1]!.x, 400); + + const enabled = bitmouseEnabledStages(block!); + + assert.equal(enabled.length, 2); + assert.deepEqual(enabled.map((stage) => stage.x), [800, 400]); + // Colour bytes survive in the empty slots, so DPI is the only usable signal. + assert.equal(block!.stages[2]!.red, 0x90); +}); + +/** + * The stored record is eight bytes, and a write prefixes it with the stage + * index — so the write byte that lines up with the record's last byte is the + * literal 0 at +8, and `enable` at +9 has no counterpart in the table at all. + * Reading the record's last byte back as an enable flag is what made a DPI + * write store the value without the sensor ever adopting it. + */ +test("enable is a command bit, not the record byte that reads back as reserved", () => { + const on = bitmouseEncodeRequest(bitmouseSetDpiRequest({ + index: 0, x: 800, y: 800, red: 0xff, green: 0, blue: 0, enable: true, + })); + const off = bitmouseEncodeRequest(bitmouseSetDpiRequest({ + index: 0, x: 800, y: 800, red: 0xff, green: 0, blue: 0, enable: false, + })); + + // Frame offset 7 is payload[0]; the record byte and the enable bit follow. + assert.equal(on[7 + 8], 0, "the byte the table stores stays a literal zero"); + assert.equal(on[7 + 9], 1); + assert.equal(off[7 + 9], 0); +}); + +test("the PAW3950 Ultra DPI ladder changes step at 10k and 30k", () => { + const options = bitmouseDpiOptions(BITMOUSE_DPI_RANGES.PAW3950Ultra); + + assert.equal(options[0], 10); + assert.equal(options[options.length - 1], 42000); + assert.ok(options.includes(1600)); + assert.ok(options.includes(10050)); + assert.ok(options.includes(30100)); + assert.ok(!options.includes(10025)); + assert.ok(!options.includes(30050)); + // Strictly ascending, no duplicates. + assert.ok(options.every((dpi, index) => index === 0 || dpi > options[index - 1]!)); +}); + +test("sleep is written as little-endian seconds", () => { + const frame = bitmouseEncodeRequest(bitmouseSetSleepRequest(1800)); + + assert.equal(frame[5], BITMOUSE_COMMAND.setSensorSleepTime); + assert.deepEqual([...frame.slice(7, 9)], [0x08, 0x07]); +}); + +/** + * The vendor software shows lift-off as a 0.7-1.7 mm slider. That is the A9 + * register scale: tenths of a millimetre offset by six, carried here in the + * config block's offsetCalibration byte rather than its silentHeight byte, + * which reads zero on an ATK ZERO. + */ +test("lift-off codes are tenths of a millimetre offset by six", () => { + assert.equal(bitmouseLiftOffMillimetres(1), 0.7); + assert.equal(bitmouseLiftOffMillimetres(4), 1); + assert.equal(bitmouseLiftOffMillimetres(11), 1.7); + assert.equal(bitmouseLiftOffMillimetres(0), null); + + for (const mm of [0.7, 1, 1.2, 1.7]) { + assert.equal(bitmouseLiftOffMillimetres(bitmouseLiftOffCode(mm)), mm); + } +}); + +test("lift-off is carried by offsetCalibration, not the silentHeight byte", () => { + // An ATK ZERO reporting offsetCalibration 0 is sitting at 0.7 mm. + assert.equal(bitmouseDecodeLiftOffLevel(0), 1); + assert.equal(bitmouseLiftOffMillimetres(bitmouseDecodeLiftOffLevel(0)), 0.7); + + const frame = bitmouseEncodeRequest(bitmouseSetLiftOffRequest(11)); + + assert.equal(frame[5], BITMOUSE_COMMAND.setSilentHeight); + assert.deepEqual([...frame.slice(7, 9)], [0, 10]); +}); + +test("a lift-off code outside the register range is refused", () => { + assert.throws(() => bitmouseSetLiftOffRequest(0), /runs 1 to 11/); + assert.throws(() => bitmouseSetLiftOffRequest(12), /runs 1 to 11/); +}); + +test("sensor sampling modes round trip across the vendor's three codes", () => { + for (const name of ["Eco", "High", "Ultra"] as const) { + assert.equal(bitmouseDecodeSensorMode(bitmouseEncodeSensorMode(name)!), name); + } + assert.equal(bitmouseDecodeSensorMode(3), null); + assert.equal(bitmouseEncodeSensorMode("Turbo"), null); + + const frame = bitmouseEncodeRequest(bitmouseSetSensorModeRequest(bitmouseEncodeSensorMode("Ultra")!)); + + assert.equal(frame[5], BITMOUSE_COMMAND.setSensorModel); + assert.equal(frame[7], 5); +}); + +test("long-range mode is a one-byte write read back from its own address", () => { + assert.equal(BITMOUSE_ADDRESS.farDistance, 75); + assert.equal(BITMOUSE_ADDRESS.sensorModel, 74); + + const on = bitmouseEncodeRequest(bitmouseSetFarDistanceRequest(true)); + const off = bitmouseEncodeRequest(bitmouseSetFarDistanceRequest(false)); + + assert.equal(on[5], BITMOUSE_COMMAND.setFarDistance); + assert.equal(on[7], 1); + assert.equal(off[7], 0); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index 8021702..8e3285a 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -194,6 +194,11 @@ export interface MouseStatus { /** How many Nape onboard layers VIA reported. Undefined when unread. */ napeLayerCount?: number; performanceMode?: boolean | null; + /** + * Long-range / far-distance radio mode: trades battery for link range on + * receivers that offer it (ATK calls it Ultra Long Range). + */ + longRangeMode?: boolean | null; hyperMode?: boolean | null; sensorMode?: "Eco" | "High" | "Ultra" | null; sensorModeStored?: 0 | 1 | null; @@ -208,6 +213,22 @@ export interface MouseStatus { dpiLedBrightness?: number | null; dpiLedSpeed?: number | null; liftOffDistance: "Low" | "Medium" | "High" | null; + /** + * A single lift-off height the device tunes continuously, for mice whose + * firmware exposes a range rather than the three Low/Medium/High stops. The + * value and bounds are raw device codes; the millimetre figures are what the + * control labels. Drivers that only offer the stops leave this undefined, and + * a driver that sets it should still fill `liftOffDistance` with the nearest + * stop so anything reading the coarse field keeps working. + */ + liftOffScale?: { + value: number; + min: number; + max: number; + millimetres: number; + minMillimetres: number; + maxMillimetres: number; + } | null; /** Explicit LOD choices when a mouse does not support all three common levels. */ supportedLiftOffDistances?: Array>; /** diff --git a/src/drivers/registry.test.ts b/src/drivers/registry.test.ts index aa0c365..105286f 100644 --- a/src/drivers/registry.test.ts +++ b/src/drivers/registry.test.ts @@ -12,7 +12,7 @@ import { ORBITAL_DEVICES } from "@openmouse/protocol/orbital"; const DEVICES_DIR = dirname(fileURLToPath(import.meta.url)); const REPORT_IDS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 0x0f, 0x10, 0x11, 0x20, 0xa1, 0xb3, 0xb4]; -const USAGE_PAGES = [0x01, 0x0c, 0xff, 0xff00, 0xff01, 0xff02, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc1, 0xffff]; +const USAGE_PAGES = [0x01, 0x0c, 0xff, 0xff00, 0xff01, 0xff02, 0xff05, 0xff0a, 0xff1c, 0xff43, 0xff55, 0xff60, 0xffa0, 0xffc1, 0xffff]; function report(reportId: number, byteLength = 16): HIDReportInfo { return { reportId, items: [{ reportSize: 8, reportCount: byteLength }] } as unknown as HIDReportInfo; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index 80d7219..2a80fc7 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -1,3 +1,4 @@ +import { AtkBitmouseHidClient } from "./atk/bitmouse-hid.ts"; import { AtkHidClient } from "./atk/hid.ts"; import { AttackSharkHidClient } from "./attackshark/hid.ts"; import { EggOp1HidClient } from "./endgame/egg-op1-hid.ts"; @@ -42,7 +43,7 @@ import { GloriousHidClient } from "./glorious/hid.ts"; import { GloriousClassicHidClient } from "./glorious/classic-hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronM6HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | AtkBitmouseHidClient | VgnF2HidClient | KeychronM6HidClient | KeychronNapeHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | SteelSeriesRival3HidClient | SteelSeriesAerox3HidClient | SteelSeriesRival3WirelessHidClient | SteelSeriesAerox5HidClient | SteelSeriesAerox5WirelessHidClient | SteelSeriesRival650HidClient | SteelSeriesAerox9WirelessHidClient | SteelSeriesRival310HidClient | SteelSeriesPrimePlusHidClient | SteelSeriesPrimeMiniWirelessHidClient | SteelSeriesSenseiTenHidClient | GloriousHidClient | GloriousClassicHidClient; export interface DeviceDriver { brand: string; @@ -71,6 +72,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "Razer", supports: (device) => RazerCobraHidClient.isSupported(device), create: (device) => new RazerCobraHidClient(device), score: () => 6 }, { brand: "Razer", supports: (device) => RazerViperMiniHidClient.isSupported(device), create: (device) => new RazerViperMiniHidClient(device), score: () => 6 }, { brand: "Razer", supports: (device) => RazerViperHidClient.isSupported(device), create: (device) => new RazerViperHidClient(device), score: () => 6 }, + { brand: "ATK", supports: (device) => AtkBitmouseHidClient.isSupported(device), create: (device) => new AtkBitmouseHidClient(device), score: () => 7 }, { brand: "ATK", supports: (device) => AtkHidClient.isSupported(device), create: (device) => new AtkHidClient(device), score: () => 5 }, { brand: "Attack Shark", supports: (device) => AttackSharkHidClient.isSupported(device), create: (device) => new AttackSharkHidClient(device), score: () => 5 }, { brand: "Razer", supports: (device) => RazerViperV4ProHidClient.isSupported(device), create: (device) => new RazerViperV4ProHidClient(device), score: () => 7 }, diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 8eb3199..f39d505 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -6,6 +6,11 @@ import { } from "@openmouse/protocol/logitech"; import { RAZER_PRODUCTS, RAZER_PRODUCT_IDS } from "@openmouse/protocol/razer-devices"; import { PULSAR_XS1_PRODUCT_IDS } from "@openmouse/protocol/pulsar"; +import { + BITMOUSE_PRODUCT_IDS, + BITMOUSE_USAGE, + BITMOUSE_USAGE_PAGE, +} from "@openmouse/protocol/bitmouse"; import { NINJUTSO_LEGACY_MOUSE_PRODUCT_IDS, NINJUTSO_LEGACY_RECEIVER_PRODUCT_IDS, @@ -412,6 +417,8 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.vgn, productId: 0xfb56 }, { vendorId: VENDOR_ID.vgn, productId: 0xfb57 }, { vendorId: VENDOR_ID.atk, usagePage: 0xff02, usage: 2 }, + ...BITMOUSE_PRODUCT_IDS.map((productId) => ( + { vendorId: VENDOR_ID.atk, productId, usagePage: BITMOUSE_USAGE_PAGE, usage: BITMOUSE_USAGE })), { vendorId: VENDOR_ID.attackShark }, { vendorId: VENDOR_ID.attackSharkX }, ...RAZER_VIPER_V4_CONTROL_FILTERS, diff --git a/src/index.ts b/src/index.ts index 582185a..5876cbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * as atk from "./atk/index.js"; +export * as bitmouse from "./bitmouse/index.js"; export * as endgameGearWe from "./endgame-gear/wireless.js"; export * as endgameGearOp1 from "./endgame-gear/op1.js"; export * as finalmouse from "./finalmouse/index.js";