Skip to content
Open
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
73 changes: 72 additions & 1 deletion src/drivers/teevolution/hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ 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_LCD_REPORT_ID,
TEEVOLUTION_REPORT_ID,
teevolutionBuildLcdTimePacket,
} from "@openmouse/protocol/teevolution";

function device(productId: number, reportId = TEEVOLUTION_REPORT_ID): HIDDevice {
return {
Expand All @@ -20,6 +24,37 @@ function device(productId: number, reportId = TEEVOLUTION_REPORT_ID): HIDDevice
} as unknown as HIDDevice;
}

function lcdDevice(productId = 0xf523): HIDDevice & { sent: Array<{ reportId: number; data: Uint8Array }> } {
const sent: Array<{ reportId: number; data: Uint8Array }> = [];
const fake = {
vendorId: 0x3554,
productId,
productName: "RapidSync LCD",
opened: false,
sent,
collections: [{
usagePage: 0xff08,
usage: 2,
children: [],
featureReports: [],
inputReports: [{ reportId: TEEVOLUTION_LCD_REPORT_ID, items: [{ reportCount: 39, reportSize: 8 }] }],
outputReports: [{ reportId: TEEVOLUTION_LCD_REPORT_ID, items: [{ reportCount: 39, reportSize: 8 }] }],
}],
async open() {
fake.opened = true;
},
async close() {
fake.opened = false;
},
async sendReport(reportId: number, data: BufferSource) {
sent.push({ reportId, data: new Uint8Array(data as Uint8Array) });
},
addEventListener() {},
removeEventListener() {},
};
return fake as unknown as HIDDevice & { sent: Array<{ reportId: number; data: Uint8Array }> };
}

test("support is limited to Terra Pro Compx transports with report 8", () => {
// Arrange
const receiver = device(0xf523);
Expand All @@ -35,4 +70,40 @@ test("support is limited to Terra Pro Compx transports with report 8", () => {
assert.equal(TeevolutionHidClient.isSupported(device(0xf522)), true);
assert.equal(TeevolutionHidClient.isSupported(wrongPid), false);
assert.equal(TeevolutionHidClient.isSupported(wrongReport), false);
assert.equal(TeevolutionHidClient.isSupported(lcdDevice()), false);
});

test("mouseOfflineMessage tells RapidSync users to wake or pair", () => {
assert.equal(
TeevolutionHidClient.mouseOfflineMessage(true),
TeevolutionHidClient.RAPIDSYNC_OFFLINE_ERROR,
);
assert.equal(
TeevolutionHidClient.mouseOfflineMessage(false),
TeevolutionHidClient.MOUSE_OFFLINE_ERROR,
);
});

test("syncDongleClock writes host time on the authorized LCD interface", async () => {
// Arrange
const config = device(0xf523);
const lcd = lcdDevice();
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { hid: { getDevices: async () => [config, lcd] } },
});
const client = new TeevolutionHidClient(config);
const now = new Date(2026, 7, 15, 21, 27, 0);
const expected = teevolutionBuildLcdTimePacket(now);

// Act
const written = await client.syncDongleClock(now);

// Assert
assert.equal(written, true);
assert.equal(lcd.sent.length, 1);
assert.equal(lcd.sent[0]?.reportId, TEEVOLUTION_LCD_REPORT_ID);
assert.deepEqual([...lcd.sent[0]!.data], [...expected.subarray(1)]);
assert.equal(await client.syncDongleClock(now), true);
assert.equal(lcd.sent.length, 1, "clock sync is once per connection");
});
212 changes: 141 additions & 71 deletions src/drivers/teevolution/hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import { VENDOR_ID } from "../vendors.ts";
import {
TEEVOLUTION_COMMAND,
TEEVOLUTION_FLASH,
TEEVOLUTION_LCD_REPORT_ID,
TEEVOLUTION_LCD_USAGE,
TEEVOLUTION_LCD_USAGE_PAGE,
TEEVOLUTION_PRODUCT_IDS,
TEEVOLUTION_REPORT_ID,
TEEVOLUTION_TYPE_MAX_POLL,
teevolutionBuildLcdTimePacket,
teevolutionBuildOnlinePayload,
teevolutionBuildReadPayload,
teevolutionBuildSimplePayload,
Expand Down Expand Up @@ -42,7 +46,20 @@ export interface TeevolutionDeviceInfo {
}

export class TeevolutionHidClient {
static readonly RAPIDSYNC_OFFLINE_ERROR =
"The RapidSync 8K dongle is connected, but the mouse is offline. Wake the mouse or pair it to this dongle, then retry.";
static readonly MOUSE_OFFLINE_ERROR =
"The Teevolution mouse is offline. Move it or click a button, then retry.";

static mouseOfflineMessage(rapidSyncDongle: boolean): string {
return rapidSyncDongle
? TeevolutionHidClient.RAPIDSYNC_OFFLINE_ERROR
: TeevolutionHidClient.MOUSE_OFFLINE_ERROR;
}

private deviceInfo: TeevolutionDeviceInfo | null = null;
private lcdClockSynced = false;
private lcdDevice: HIDDevice | null = null;
private responseWaiter: {
command: number;
resolve: (bytes: Uint8Array) => void;
Expand Down Expand Up @@ -117,83 +134,101 @@ export class TeevolutionHidClient {
maximumPollingRateHz: TEEVOLUTION_TYPE_MAX_POLL[type] ?? 1000,
profile,
};
await this.syncDongleClock().catch(() => undefined);
return this.deviceInfo;
}

/**
* Pushes host local time to a RapidSync LCD once per connection. Missing LCD
* collections (wired Terra, unauthorized sibling) are skipped.
*/
async syncDongleClock(now = new Date()): Promise<boolean> {
if (this.lcdClockSynced) return true;
const lcd = await this.findLcdClockDevice();
if (!lcd) return false;
const packet = teevolutionBuildLcdTimePacket(now);
const alreadyOpen = lcd.opened;
if (!alreadyOpen) await lcd.open();
this.lcdDevice = lcd;
await lcd.sendReport(TEEVOLUTION_LCD_REPORT_ID, new Uint8Array(packet.subarray(1)));
this.lcdClockSynced = true;
return true;
}

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,
},
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",
],
};
},
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",
],
};
});
}

Expand Down Expand Up @@ -446,6 +481,11 @@ export class TeevolutionHidClient {
this.device.removeEventListener("inputreport", this.onInputReport);
this.responseWaiter?.reject(new Error("The Teevolution device was closed."));
this.responseWaiter = null;
this.lcdClockSynced = false;
if (this.lcdDevice && this.lcdDevice !== this.device && this.lcdDevice.opened) {
await this.lcdDevice.close().catch(() => undefined);
}
this.lcdDevice = null;
if (this.device.opened) await this.device.close();
}

Expand All @@ -460,6 +500,27 @@ export class TeevolutionHidClient {
return this.deviceInfo.profile;
}

static isLcdClockInterface(device: HIDDevice): boolean {
return device.vendorId === VENDOR_ID.teevolution
&& TEEVOLUTION_PRODUCT_IDS.has(device.productId)
&& device.collections.some((collection) =>
collection.usagePage === TEEVOLUTION_LCD_USAGE_PAGE
&& collection.usage === TEEVOLUTION_LCD_USAGE
&& collection.outputReports.some((report) => report.reportId === TEEVOLUTION_LCD_REPORT_ID));
}

private async findLcdClockDevice(): Promise<HIDDevice | null> {
if (TeevolutionHidClient.isLcdClockInterface(this.device)) return this.device;
const granted = typeof navigator !== "undefined" && navigator.hid
? await navigator.hid.getDevices()
: [];
return granted.find((candidate) =>
candidate !== this.device
&& candidate.vendorId === this.device.vendorId
&& candidate.productId === this.device.productId
&& TeevolutionHidClient.isLcdClockInterface(candidate)) ?? null;
}

private decodeDpiStageCount(raw: number | undefined, profile: TeevolutionDeviceProfile): number {
return Math.min(Math.max(raw ?? profile.dpiStageCount, 1), profile.dpiStageCount);
}
Expand Down Expand Up @@ -490,7 +551,16 @@ export class TeevolutionHidClient {
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.");
if (enabled && response[5] !== 1) {
throw new Error(TeevolutionHidClient.mouseOfflineMessage(this.usesRapidSyncDongle()));
}
}

private usesRapidSyncDongle(): boolean {
if (this.device.productId === 0xf523) return true;
if (this.lcdDevice || TeevolutionHidClient.isLcdClockInterface(this.device)) return true;
return this.deviceInfo?.connection === "Wireless"
&& this.deviceInfo.maximumPollingRateHz === 8000;
}

private async readFlash(address: number, length: number): Promise<Uint8Array> {
Expand Down
19 changes: 19 additions & 0 deletions src/drivers/teevolution/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import {
teevolutionParseBattery,
teevolutionParseReadResponse,
teevolutionSensorModeUi,
teevolutionLcdChecksum,
teevolutionBuildLcdTimePacket,
TEEVOLUTION_LCD_REPORT_ID,
TEEVOLUTION_LCD_PACKET_LENGTH,
} from "@openmouse/protocol/teevolution";

test("host-control and battery commands match Compx report-8 checksums", () => {
Expand Down Expand Up @@ -194,3 +198,18 @@ test("sensor mode encoding accepts Eco and High only", () => {
assert.equal(teevolutionEncodeSensorMode("High"), 1);
assert.throws(() => teevolutionEncodeSensorMode("Ultra"), /cannot be written/);
});

test("RapidSync LCD time packets match Teevolink framing and CRC", () => {
// Arrange — Saturday 15 Aug 2026 21:27:00 local (verified on hardware)
const now = new Date(2026, 7, 15, 21, 27, 0);

// Act
const packet = teevolutionBuildLcdTimePacket(now);

// Assert
assert.equal(packet.length, TEEVOLUTION_LCD_PACKET_LENGTH);
assert.equal(packet[0], TEEVOLUTION_LCD_REPORT_ID);
assert.deepEqual([...packet.subarray(1, 11)], [0x00, 0x10, 0x07, 0xea, 0x08, 0x0f, 0x15, 0x1b, 0x00, 6]);
assert.equal(packet[39], teevolutionLcdChecksum(packet));
assert.equal(now.getDay(), 6);
});
Loading