diff --git a/src/drivers/teevolution/hid.test.ts b/src/drivers/teevolution/hid.test.ts index 4de2f11..77dc934 100644 --- a/src/drivers/teevolution/hid.test.ts +++ b/src/drivers/teevolution/hid.test.ts @@ -2,7 +2,17 @@ import assert from "node:assert/strict"; import test from "node:test"; import { TeevolutionHidClient } from "./hid.ts"; -import { TEEVOLUTION_REPORT_ID } from "@openmouse/protocol/teevolution"; +import { + TEEVOLUTION_COMMAND, + TEEVOLUTION_FLASH, + TEEVOLUTION_PACKET_LENGTH, + TEEVOLUTION_REPORT_ID, + teevolutionEncodeDpi, + teevolutionEncodePollingRate, + teevolutionPacketChecksum, +} from "@openmouse/protocol/teevolution"; + +Object.assign(globalThis, { window: globalThis }); function device(productId: number, reportId = TEEVOLUTION_REPORT_ID): HIDDevice { return { @@ -36,3 +46,113 @@ test("support is limited to Terra Pro Compx transports with report 8", () => { assert.equal(TeevolutionHidClient.isSupported(wrongPid), false); assert.equal(TeevolutionHidClient.isSupported(wrongReport), false); }); + +class FakeRapidSync { + readonly vendorId = 0x3554; + readonly productId = 0xf523; + readonly productName = "RapidSync"; + readonly collections = device(0xf523).collections; + opened = false; + readonly sent: Uint8Array[] = []; + mouseOnline = true; + + private listeners = new Map void>(); + private flash = new Uint8Array(256); + + constructor() { + this.flash[TEEVOLUTION_FLASH.reportRate] = teevolutionEncodePollingRate(1000); + this.flash[TEEVOLUTION_FLASH.reportRate + 1] = (0x55 - this.flash[0]!) & 0xff; + this.flash[TEEVOLUTION_FLASH.maxDpiStage] = 2; + this.flash[TEEVOLUTION_FLASH.currentDpi] = 1; + this.flash[TEEVOLUTION_FLASH.liftOffDistance] = 1; + this.flash.set(teevolutionEncodeDpi(1600), TEEVOLUTION_FLASH.dpiValues + 4); + } + + addEventListener(type: string, listener: (event: unknown) => void): void { + this.listeners.set(type, listener); + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + if (this.listeners.get(type) === listener) this.listeners.delete(type); + } + + async open(): Promise { + this.opened = true; + } + + async close(): Promise { + this.opened = false; + } + + async sendReport(_reportId: number, data: BufferSource): Promise { + const request = data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + this.sent.push(request.slice()); + const reply = new Uint8Array(TEEVOLUTION_PACKET_LENGTH); + reply[0] = request[0] ?? 0; + const command = request[0] ?? 0; + if (command === TEEVOLUTION_COMMAND.encryptionData) { + reply[9] = 14; + reply[10] = 3; + reply[11] = 5; + } else if (command === TEEVOLUTION_COMMAND.deviceOnline) { + reply[5] = this.mouseOnline ? 1 : 0; + } else if (command === TEEVOLUTION_COMMAND.readFlashData) { + const address = ((request[2] ?? 0) << 8) | (request[3] ?? 0); + const length = request[4] ?? 0; + reply[2] = request[2] ?? 0; + reply[3] = request[3] ?? 0; + reply[4] = length; + reply.set(this.flash.slice(address, address + length), 5); + } else if (command === TEEVOLUTION_COMMAND.writeFlashData) { + const address = ((request[2] ?? 0) << 8) | (request[3] ?? 0); + const length = request[4] ?? 0; + this.flash.set(request.slice(5, 5 + length), address); + } else if (command === TEEVOLUTION_COMMAND.batteryLevel) { + reply[5] = 85; + } else if (command === TEEVOLUTION_COMMAND.readVersionId || command === TEEVOLUTION_COMMAND.getDongleVersion) { + reply[5] = 2; + reply[6] = 0x21; + } + reply[15] = teevolutionPacketChecksum(reply); + queueMicrotask(() => { + this.listeners.get("inputreport")?.({ + reportId: TEEVOLUTION_REPORT_ID, + data: new DataView(reply.buffer, reply.byteOffset, reply.byteLength), + }); + }); + } +} + +function onlineFlags(sent: readonly Uint8Array[]): number[] { + return sent.filter((packet) => packet[0] === TEEVOLUTION_COMMAND.deviceOnline).map((packet) => packet[5] ?? 0); +} + +test("status reads query online without taking host-control", async () => { + // Arrange + const hid = new FakeRapidSync(); + const client = new TeevolutionHidClient(hid as unknown as HIDDevice); + + // Act + const status = await client.readStatus(); + + // Assert + assert.equal(status.pollingRateHz, 1000); + assert.deepEqual(onlineFlags(hid.sent), [0]); +}); + +test("polling writes still enter and leave host-control", async () => { + // Arrange + const hid = new FakeRapidSync(); + const client = new TeevolutionHidClient(hid as unknown as HIDDevice); + await client.readDeviceInfo(); + hid.sent.length = 0; + + // Act + const confirmed = await client.setPollingRate(2000); + + // Assert + assert.equal(confirmed, 2000); + assert.deepEqual(onlineFlags(hid.sent), [1, 0]); +}); diff --git a/src/drivers/teevolution/hid.ts b/src/drivers/teevolution/hid.ts index 890013b..ed484a5 100644 --- a/src/drivers/teevolution/hid.ts +++ b/src/drivers/teevolution/hid.ts @@ -122,79 +122,78 @@ export class TeevolutionHidClient { async readStatus(): Promise { const info = this.deviceInfo ?? await this.readDeviceInfo(); - return await this.withDeviceControl(async () => { - const flash = await this.readFlash(TEEVOLUTION_FLASH.reportRate, TEEVOLUTION_FLASH.sensorMode + 2); - const batteryResponse = await this.query(TEEVOLUTION_COMMAND.batteryLevel); - const deviceVersion = await this.query(TEEVOLUTION_COMMAND.readVersionId); - const dongleVersion = await this.query(TEEVOLUTION_COMMAND.getDongleVersion).catch(() => null); - const profile = await this.query(TEEVOLUTION_COMMAND.getCurrentConfig).catch(() => null); - const rssi = await this.query(TEEVOLUTION_COMMAND.getRssi).catch(() => null); - const stageCount = this.decodeDpiStageCount(flash[TEEVOLUTION_FLASH.maxDpiStage], info.profile); - const activeDpiStage = Math.min(flash[TEEVOLUTION_FLASH.currentDpi] ?? 0, stageCount - 1); - const dpiStages = Array.from({ length: stageCount }, (_, stage) => { - const offset = TEEVOLUTION_FLASH.dpiValues + stage * 4; - return teevolutionDecodeDpi(flash.slice(offset, offset + 4)); - }); - const dpi = dpiStages[activeDpiStage] ?? dpiStages[0] ?? 0; - const battery = teevolutionParseBattery(batteryResponse); - const pollingRateHz = teevolutionDecodePollingRate(flash[TEEVOLUTION_FLASH.reportRate] ?? 1); - const sensorModeUi = teevolutionSensorModeUi({ - storedMode: flash[TEEVOLUTION_FLASH.sensorMode] ?? 0, - pollingRateHz, - connection: info.connection, - }); - return { - brand: "Teevolution", - name: this.displayName(info), - ui: { - defaultDisplayName: info.profile.name, - hideUnsupportedPollingRates: true, - hideSignalCard: true, - dpiStageEditor: { - maxStages: info.profile.dpiStageCount, - countEditable: true, - minDpi: info.profile.dpi.min, - maxDpi: info.profile.dpi.max, - stepDpi: info.profile.dpi.standardStep, - }, - }, - batteryPercent: battery?.percent ?? null, - batteryState: battery?.charging ? "Charging" : "Discharging", - dpi, - dpiStages, - activeDpiStage, - pollingRateHz, - supportedPollingRates: info.profile.pollingRates.filter((rate) => rate <= info.maximumPollingRateHz), - activeProfile: profile && profile[1] === 0 ? (profile[5] ?? 0) + 1 : null, - connectionType: info.connection, - connectionDetail: `CID ${info.cid} · MID ${info.mid} · Type ${info.type}`, - signalStrength: rssi && rssi[1] === 0 ? Math.min(rssi[5] ?? 0, 4) : null, - dpiLedMode: teevolutionDecodeDpiLightMode( - flash[TEEVOLUTION_FLASH.dpiLightMode] ?? 1, - flash[TEEVOLUTION_FLASH.dpiLightState] ?? 0, - ), - dpiLedBrightness: teevolutionDecodeDpiLightBrightness( - flash[TEEVOLUTION_FLASH.dpiLightBrightness] ?? 0x80, - ), - dpiLedSpeed: Math.min(Math.max(flash[TEEVOLUTION_FLASH.dpiLightSpeed] ?? 3, 1), 5), - debounceMs: flash[TEEVOLUTION_FLASH.debounceTime] ?? null, - motionSync: flash[TEEVOLUTION_FLASH.motionSync] === 1, - sleepTimeout: flash[TEEVOLUTION_FLASH.sleepTime] ?? null, - angleSnapping: flash[TEEVOLUTION_FLASH.angleSnapping] === 1, - rippleControl: flash[TEEVOLUTION_FLASH.rippleControl] === 1, - performanceMode: flash[TEEVOLUTION_FLASH.performanceState] === 1, - performanceDuration: flash[TEEVOLUTION_FLASH.performanceTime] ?? null, - sensorMode: sensorModeUi.mode, - sensorModeStored: sensorModeUi.storedValue, - sensorModeEditable: sensorModeUi.editable, - liftOffDistance: teevolutionDecodeLiftOff(flash[TEEVOLUTION_FLASH.liftOffDistance] ?? -1), - supportedLiftOffDistances: [...info.profile.liftOffDistances], - firmware: [ - this.decodeVersionOptional("Mouse", deviceVersion) ?? "Mouse firmware unavailable", - this.decodeVersionOptional("Dongle", dongleVersion) ?? "Dongle firmware unavailable", - ], - }; + await this.requireMouseOnline(); + const flash = await this.readFlash(TEEVOLUTION_FLASH.reportRate, TEEVOLUTION_FLASH.sensorMode + 2); + const batteryResponse = await this.query(TEEVOLUTION_COMMAND.batteryLevel); + const deviceVersion = await this.query(TEEVOLUTION_COMMAND.readVersionId); + const dongleVersion = await this.query(TEEVOLUTION_COMMAND.getDongleVersion).catch(() => null); + const profile = await this.query(TEEVOLUTION_COMMAND.getCurrentConfig).catch(() => null); + const rssi = await this.query(TEEVOLUTION_COMMAND.getRssi).catch(() => null); + const stageCount = this.decodeDpiStageCount(flash[TEEVOLUTION_FLASH.maxDpiStage], info.profile); + const activeDpiStage = Math.min(flash[TEEVOLUTION_FLASH.currentDpi] ?? 0, stageCount - 1); + const dpiStages = Array.from({ length: stageCount }, (_, stage) => { + const offset = TEEVOLUTION_FLASH.dpiValues + stage * 4; + return teevolutionDecodeDpi(flash.slice(offset, offset + 4)); + }); + const dpi = dpiStages[activeDpiStage] ?? dpiStages[0] ?? 0; + const battery = teevolutionParseBattery(batteryResponse); + const pollingRateHz = teevolutionDecodePollingRate(flash[TEEVOLUTION_FLASH.reportRate] ?? 1); + const sensorModeUi = teevolutionSensorModeUi({ + storedMode: flash[TEEVOLUTION_FLASH.sensorMode] ?? 0, + pollingRateHz, + connection: info.connection, }); + return { + brand: "Teevolution", + name: this.displayName(info), + ui: { + defaultDisplayName: info.profile.name, + hideUnsupportedPollingRates: true, + hideSignalCard: true, + dpiStageEditor: { + maxStages: info.profile.dpiStageCount, + countEditable: true, + minDpi: info.profile.dpi.min, + maxDpi: info.profile.dpi.max, + stepDpi: info.profile.dpi.standardStep, + }, + }, + batteryPercent: battery?.percent ?? null, + batteryState: battery?.charging ? "Charging" : "Discharging", + dpi, + dpiStages, + activeDpiStage, + pollingRateHz, + supportedPollingRates: info.profile.pollingRates.filter((rate) => rate <= info.maximumPollingRateHz), + activeProfile: profile && profile[1] === 0 ? (profile[5] ?? 0) + 1 : null, + connectionType: info.connection, + connectionDetail: `CID ${info.cid} · MID ${info.mid} · Type ${info.type}`, + signalStrength: rssi && rssi[1] === 0 ? Math.min(rssi[5] ?? 0, 4) : null, + dpiLedMode: teevolutionDecodeDpiLightMode( + flash[TEEVOLUTION_FLASH.dpiLightMode] ?? 1, + flash[TEEVOLUTION_FLASH.dpiLightState] ?? 0, + ), + dpiLedBrightness: teevolutionDecodeDpiLightBrightness( + flash[TEEVOLUTION_FLASH.dpiLightBrightness] ?? 0x80, + ), + dpiLedSpeed: Math.min(Math.max(flash[TEEVOLUTION_FLASH.dpiLightSpeed] ?? 3, 1), 5), + debounceMs: flash[TEEVOLUTION_FLASH.debounceTime] ?? null, + motionSync: flash[TEEVOLUTION_FLASH.motionSync] === 1, + sleepTimeout: flash[TEEVOLUTION_FLASH.sleepTime] ?? null, + angleSnapping: flash[TEEVOLUTION_FLASH.angleSnapping] === 1, + rippleControl: flash[TEEVOLUTION_FLASH.rippleControl] === 1, + performanceMode: flash[TEEVOLUTION_FLASH.performanceState] === 1, + performanceDuration: flash[TEEVOLUTION_FLASH.performanceTime] ?? null, + sensorMode: sensorModeUi.mode, + sensorModeStored: sensorModeUi.storedValue, + sensorModeEditable: sensorModeUi.editable, + liftOffDistance: teevolutionDecodeLiftOff(flash[TEEVOLUTION_FLASH.liftOffDistance] ?? -1), + supportedLiftOffDistances: [...info.profile.liftOffDistances], + firmware: [ + this.decodeVersionOptional("Mouse", deviceVersion) ?? "Mouse firmware unavailable", + this.decodeVersionOptional("Dongle", dongleVersion) ?? "Dongle firmware unavailable", + ], + }; } getDpiOptions(): number[] { @@ -481,16 +480,34 @@ export class TeevolutionHidClient { } } + /** Teevolink Fa(): cmd 0x03 with [5] left 0, then require the mouse-present flag. */ + private async requireMouseOnline(): Promise { + const response = await this.exchangeDeviceOnline(false, "online check"); + if (response[5] !== 1) { + throw new Error("The Teevolution mouse is offline. Move it or click a button, then retry."); + } + } + private async setDeviceOnline(enabled: boolean): Promise { + const response = await this.exchangeDeviceOnline( + enabled, + enabled ? "host-control entry" : "host-control exit", + ); + if (enabled && response[5] !== 1) { + throw new Error("The Teevolution mouse is offline. Move it or click a button, then retry."); + } + } + + private async exchangeDeviceOnline(enabled: boolean, operation: string): Promise { let response: Uint8Array | null = null; for (let attempt = 0; attempt < 20; attempt += 1) { response = await this.exchange(teevolutionBuildOnlinePayload(enabled)); - this.assertAccepted(response, enabled ? "host-control entry" : "host-control exit"); + this.assertAccepted(response, operation); if (response[9] !== 1) break; await new Promise((resolve) => window.setTimeout(resolve, 10)); } if (!response || response[9] === 1) throw new Error("The Teevolution receiver stayed busy."); - if (enabled && response[5] !== 1) throw new Error("The Teevolution mouse is offline. Move it or click a button, then retry."); + return response; } private async readFlash(address: number, length: number): Promise { diff --git a/src/drivers/teevolution/protocol.test.ts b/src/drivers/teevolution/protocol.test.ts index 0e43d36..da8b2b0 100644 --- a/src/drivers/teevolution/protocol.test.ts +++ b/src/drivers/teevolution/protocol.test.ts @@ -31,14 +31,16 @@ test("host-control and battery commands match Compx report-8 checksums", () => { // Arrange — same framing Teevolink uses on report ID 0x08 const requests = [ teevolutionBuildOnlinePayload(true), + teevolutionBuildOnlinePayload(false), teevolutionBuildSimplePayload(0x04), teevolutionBuildReadPayload(0x00a0, 10), ]; // Act / Assert assert.deepEqual([...requests[0]!], [0x03, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x49]); - assert.deepEqual([...requests[1]!], [0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x49]); - assert.deepEqual([...requests[2]!], [0x08, 0, 0, 0xa0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x9b]); + assert.deepEqual([...requests[1]!], [0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x4a]); + assert.deepEqual([...requests[2]!], [0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x49]); + assert.deepEqual([...requests[3]!], [0x08, 0, 0, 0xa0, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x9b]); assert.equal(TEEVOLUTION_REPORT_ID, 0x08); });