Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 121 additions & 1 deletion src/drivers/teevolution/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, (event: unknown) => 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<void> {
this.opened = true;
}

async close(): Promise<void> {
this.opened = false;
}

async sendReport(_reportId: number, data: BufferSource): Promise<void> {
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]);
});
165 changes: 91 additions & 74 deletions src/drivers/teevolution/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,79 +122,78 @@ export class TeevolutionHidClient {

async readStatus(): Promise<MouseStatus> {
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[] {
Expand Down Expand Up @@ -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<void> {
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<void> {
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<Uint8Array> {
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<void>((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<Uint8Array> {
Expand Down
6 changes: 4 additions & 2 deletions src/drivers/teevolution/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down