From c3745010f2b99be5aef296e6495007ed2c295f88 Mon Sep 17 00:00:00 2001 From: itsnttt Date: Sat, 5 Sep 2026 07:03:05 +0700 Subject: [PATCH 1/2] bitmouse: support the ATK ZERO on wired and receiver transports ATK's newer mice use a different configuration channel from the A9 family already in atk/hid.ts: 63-byte frames on report 0x08 over a vendor collection at usage page 0xff05 / usage 0x0001, rather than 16-byte EEPROM commands on 0xff02 / 0x0002. The existing ATK request filter matches only the older collection, so an ATK ZERO never reaches the browser picker at all. Add the framing as a codec under src/bitmouse -- the name the vendor software gives this protocol family -- and a driver for the two ATK ZERO USB ids, wired and behind its 8K receiver. Only those two products are claimed; the same protocol drives many more models, but each needs its own hardware check first. Verified on an ATK ZERO, mouse firmware 3.0.3, receiver firmware 3.0.2, over both transports: reads device version, cid/mid (1,1), device type, battery level and charging state, the config block, the DPI stage table writes each confirmed by reading back, then restored: polling rate 2000 -> 1000 Hz, DPI 800 -> 1600, debounce 4 -> 8 ms, sleep 1800 -> 600 s, motion sync and ripple control on -> off Two firmware behaviours the tests pin down: a reply leaves the previous exchange's bytes past its own length, so payloads are trimmed to the length the device reports; and the DPI table's count byte reads 8 while only two stages hold a value, so the usable stages are the leading run with a non-zero DPI. Left unreported rather than guessed at: lift-off distance, where the config byte reads zero on both transports and the vendor's own field map overlaps a 16-bit DPI value with it; angle snapping, which has a command but was not exercised; chip id and the online-status byte, which both answer zero on hardware that is plainly online. Co-Authored-By: Claude Opus 5 --- package.json | 4 + src/bitmouse/index.ts | 430 ++++++++++++++++++++ src/drivers/atk/bitmouse-hid.ts | 457 ++++++++++++++++++++++ src/drivers/atk/bitmouse-protocol.test.ts | 245 ++++++++++++ src/drivers/registry.test.ts | 2 +- src/drivers/registry.ts | 4 +- src/drivers/vendors.ts | 7 + src/index.ts | 1 + 8 files changed, 1148 insertions(+), 2 deletions(-) create mode 100644 src/bitmouse/index.ts create mode 100644 src/drivers/atk/bitmouse-hid.ts create mode 100644 src/drivers/atk/bitmouse-protocol.test.ts 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..17b6d85 --- /dev/null +++ b/src/bitmouse/index.ts @@ -0,0 +1,430 @@ +/** + * 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, + 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], + 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; +} + +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 a stage; the vendor writes 1 for the stage it enables. */ + flag: 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]!, + flag: 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; + 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..68ace54 --- /dev/null +++ b/src/drivers/atk/bitmouse-hid.ts @@ -0,0 +1,457 @@ +import { + bitmouseAddressDataRequest, + bitmouseDecodeCidMid, + bitmouseDecodeConfig, + bitmouseDecodeDpiBlock, + bitmouseDecodeReply, + bitmouseDecodeVersion, + bitmouseDpiBlockAddresses, + bitmouseDpiOptions, + bitmouseEnabledStages, + bitmouseEncodePollingRate, + bitmouseEncodeRequest, + bitmouseSetDpiRequest, + bitmouseSetFlagRequest, + bitmouseSetSleepRequest, + BITMOUSE_ADDRESS_DATA_OFFSET, + BITMOUSE_COMMAND, + BITMOUSE_DPI_BLOCK_CHUNK, + BITMOUSE_DPI_RANGES, + BITMOUSE_LENGTHS, + 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; + +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 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, + // No angle-snapping or lift-off command has been confirmed on hardware. + hideAngleSnapping: 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, + angleSnapping: null, + // The lift-off byte in the config block reads zero on both transports and + // the vendor's own field map overlaps there, so it is left unreported. + liftOffDistance: null, + 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; + } + + async setDpi(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 block = this.dpiBlock; + const index = block?.currentIndex ?? 0; + const stage = block?.stages[index]; + if (!stage) throw new Error("The mouse did not report its DPI stages."); + + // Colour and the enable flag ride along with every DPI write, so both are + // carried over from the stage as read rather than reset to a default. + await this.write(bitmouseSetDpiRequest({ + index, + x: dpi, + y: dpi, + red: stage.red, + green: stage.green, + blue: stage.blue, + enable: stage.flag !== 0, + })); + this.dpiBlock = await this.readDpiBlock(); + const confirmed = this.activeStage(); + if (confirmed !== dpi) { + throw new Error(`The mouse kept ${confirmed?.toLocaleString() ?? "an unknown value"} instead of ${dpi.toLocaleString()} DPI.`); + } + this.patch({ dpi }); + return dpi; + } + + 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..eb1d14e --- /dev/null +++ b/src/drivers/atk/bitmouse-protocol.test.ts @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + bitmouseAddressDataRequest, + bitmouseChecksum, + bitmouseDecodeCidMid, + bitmouseDecodeConfig, + bitmouseDecodeDeviceType, + bitmouseDecodeDpiBlock, + bitmouseDecodePollingRate, + bitmouseDecodeReply, + bitmouseDecodeVersion, + bitmouseDpiBlockAddresses, + bitmouseDpiOptions, + bitmouseEnabledStages, + bitmouseEncodePollingRate, + bitmouseEncodeRequest, + bitmouseSetDpiRequest, + bitmouseSetSleepRequest, + BITMOUSE_COMMAND, + BITMOUSE_COMMAND_CODE, + 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, flag: 1 }); + assert.deepEqual(decoded!.stages[1], { x: 800, y: 1000, blue: 0, green: 0, red: 0, flag: 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); +}); + +test("a DPI write can carry the stage's existing enable flag through", () => { + const off = bitmouseEncodeRequest(bitmouseSetDpiRequest({ + index: 0, x: 800, y: 800, red: 0xff, green: 0, blue: 0, enable: false, + })); + + assert.equal(off[16], 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]); +}); 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"; From d3eccc8888f17448c373829a150e998a8cf477d8 Mon Sep 17 00:00:00 2001 From: itsnttt Date: Sat, 5 Sep 2026 08:14:31 +0700 Subject: [PATCH 2/2] bitmouse: fix the DPI enable bit, and add lift-off, sensor mode and long range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driving the ATK ZERO from the OpenMouse app rather than a unit test turned up a defect the read-back verification could not catch, plus four settings the first pass either mis-read or left out. The DPI enable bit. A stage record is eight bytes and a write prefixes it with the stage index, so the write byte lining up with the record's last byte is a literal zero at +8, and `enable` at +9 has no counterpart in the stored table at all. Reading that last byte back as an enable flag and feeding it to the next write meant every DPI write sent enable=0: the mouse stored the value, answered the read-back with it, and never moved the sensor onto it. `enable` is a command bit, set when the stage being written is the active one, so it is computed that way now. The byte the table does store is renamed `reserved` so it is not mistaken for a flag again. Lift-off. The level is not in the config block's silentHeight byte, which reads zero: the vendor reads it as offsetCalibration + 1 and writes it as { height: 0, offsetCalibration: level - 1 }. The level is the A9 register scale, tenths of a millimetre offset by six, so codes 1 to 11 are 0.7 mm to 1.7 mm — a range, not the three stops first assumed. Both are reported: the nearest stop for the shared control, and the range in `liftOffScale` for a mouse-specific slider. Straight line correction is angle snapping under the vendor's name, so it is reported and written through the shared control instead of hidden. Sensor sampling mode and Ultra Long Range are read with getAddressData at addresses 74 and 75 and written with commands 31 and 27. Long range needs a field of its own, so `longRangeMode` joins the shared status. `setDpiStageValue` implements the stage editor the driver already advertised through `ui.dpiStageEditor` but could not service. Verified on an ATK ZERO over its receiver: DPI 800 -> 1600, polling 2000 -> 1000 Hz, debounce 4 -> 8 ms, sleep 1800 -> 600 s, motion sync and ripple on -> off, each read back and restored. Lift-off, angle snapping, sensor mode and long range are decoded from the vendor software and build clean, but have not yet been exercised on hardware. Co-Authored-By: Claude Opus 5 --- src/bitmouse/index.ts | 99 +++++++++++- src/drivers/atk/bitmouse-hid.ts | 181 ++++++++++++++++++++-- src/drivers/atk/bitmouse-protocol.test.ts | 88 ++++++++++- src/drivers/mouse-types.ts | 21 +++ 4 files changed, 368 insertions(+), 21 deletions(-) diff --git a/src/bitmouse/index.ts b/src/bitmouse/index.ts index 17b6d85..db2aed1 100644 --- a/src/bitmouse/index.ts +++ b/src/bitmouse/index.ts @@ -53,6 +53,8 @@ export const BITMOUSE_COMMAND = { setReportRate: 1, setDpi: 2, setSilentHeight: 3, + setFarDistance: 27, + setSensorModel: 31, getBatteryLevel: 7, getCurrentMouseConfig: 9, setLinearCorrection: 11, @@ -79,6 +81,8 @@ 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], @@ -190,6 +194,86 @@ 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; @@ -240,8 +324,12 @@ export interface BitmouseDpiStage { red: number; green: number; blue: number; - /** Byte 7 of a stage; the vendor writes 1 for the stage it enables. */ - flag: 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 { @@ -280,7 +368,7 @@ export function bitmouseDecodeDpiBlock(bytes: Uint8Array | readonly number[]): B blue: bytes[at + 4]!, green: bytes[at + 5]!, red: bytes[at + 6]!, - flag: bytes[at + 7]!, + reserved: bytes[at + 7]!, }); } return { currentIndex: bytes[0]!, stageCount: bytes[1]!, stages }; @@ -359,6 +447,11 @@ export interface BitmouseDpiWrite { 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; } diff --git a/src/drivers/atk/bitmouse-hid.ts b/src/drivers/atk/bitmouse-hid.ts index 68ace54..69e3176 100644 --- a/src/drivers/atk/bitmouse-hid.ts +++ b/src/drivers/atk/bitmouse-hid.ts @@ -4,20 +4,30 @@ import { 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, @@ -58,6 +68,20 @@ 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; @@ -151,6 +175,12 @@ export class AtkBitmouseHidClient { }; } + 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 @@ -162,8 +192,6 @@ export class AtkBitmouseHidClient { ui: { family: "atk-bitmouse", hideUnsupportedPollingRates: true, - // No angle-snapping or lift-off command has been confirmed on hardware. - hideAngleSnapping: true, showAdvancedSection: true, forceShowBattery: battery !== null, dpiStageEditor: stages.length @@ -191,10 +219,14 @@ export class AtkBitmouseHidClient { rippleControl: config?.rippleControl ?? null, debounceMs: config?.debounceMs ?? null, sleepTimeout: config?.sleepSeconds || null, - angleSnapping: null, - // The lift-off byte in the config block reads zero on both transports and - // the vendor's own field map overlaps there, so it is left unreported. - liftOffDistance: 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(), }; } @@ -215,19 +247,30 @@ export class AtkBitmouseHidClient { 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 block = this.dpiBlock; - const index = block?.currentIndex ?? 0; - const stage = block?.stages[index]; + const stage = this.dpiBlock?.stages[index]; if (!stage) throw new Error("The mouse did not report its DPI stages."); - // Colour and the enable flag ride along with every DPI write, so both are - // carried over from the stage as read rather than reset to a default. + // 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, @@ -235,17 +278,127 @@ export class AtkBitmouseHidClient { red: stage.red, green: stage.green, blue: stage.blue, - enable: stage.flag !== 0, + enable: index === active, })); this.dpiBlock = await this.readDpiBlock(); - const confirmed = this.activeStage(); + 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.`); } - this.patch({ 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, diff --git a/src/drivers/atk/bitmouse-protocol.test.ts b/src/drivers/atk/bitmouse-protocol.test.ts index eb1d14e..4bd12d7 100644 --- a/src/drivers/atk/bitmouse-protocol.test.ts +++ b/src/drivers/atk/bitmouse-protocol.test.ts @@ -12,14 +12,23 @@ import { 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, @@ -173,8 +182,8 @@ test("DPI stages decode little-endian with the colour stored blue first", () => 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, flag: 1 }); - assert.deepEqual(decoded!.stages[1], { x: 800, y: 1000, blue: 0, green: 0, red: 0, flag: 0 }); + 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); }); @@ -215,12 +224,25 @@ test("only the stages carrying a DPI value are treated as enabled", () => { assert.equal(block!.stages[2]!.red, 0x90); }); -test("a DPI write can carry the stage's existing enable flag through", () => { +/** + * 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, })); - assert.equal(off[16], 0); + // 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", () => { @@ -243,3 +265,61 @@ test("sleep is written as little-endian seconds", () => { 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>; /**