diff --git a/src/drivers/teevolution/hid.test.ts b/src/drivers/teevolution/hid.test.ts index 4de2f11..3cdac7b 100644 --- a/src/drivers/teevolution/hid.test.ts +++ b/src/drivers/teevolution/hid.test.ts @@ -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 { @@ -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); @@ -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"); }); diff --git a/src/drivers/teevolution/hid.ts b/src/drivers/teevolution/hid.ts index 890013b..0dc0772 100644 --- a/src/drivers/teevolution/hid.ts +++ b/src/drivers/teevolution/hid.ts @@ -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, @@ -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; @@ -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 { + 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 { 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", + ], + }; }); } @@ -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(); } @@ -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 { + 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); } @@ -490,7 +551,16 @@ export class TeevolutionHidClient { 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."); + 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 { diff --git a/src/drivers/teevolution/protocol.test.ts b/src/drivers/teevolution/protocol.test.ts index 0e43d36..0ecdd01 100644 --- a/src/drivers/teevolution/protocol.test.ts +++ b/src/drivers/teevolution/protocol.test.ts @@ -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", () => { @@ -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); +}); diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index 8034ae3..c792e1d 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -17,6 +17,10 @@ import { ZAUNKOENIG_USAGE_PAGE, ZAUNKOENIG_VENDOR_ID, } from "@openmouse/protocol/zaunkoenig"; +import { + TEEVOLUTION_LCD_USAGE, + TEEVOLUTION_LCD_USAGE_PAGE, +} from "@openmouse/protocol/teevolution"; export const VENDOR_ID = { pulsar: 0x3710, @@ -221,6 +225,12 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ { vendorId: VENDOR_ID.lamzu }, { vendorId: VENDOR_ID.orbital, usagePage: 0xff0a, usage: 1 }, ...TEEVOLUTION_PRODUCT_IDS.map((productId) => ({ vendorId: VENDOR_ID.teevolution, productId })), + ...TEEVOLUTION_PRODUCT_IDS.map((productId) => ({ + vendorId: VENDOR_ID.teevolution, + productId, + usagePage: TEEVOLUTION_LCD_USAGE_PAGE, + usage: TEEVOLUTION_LCD_USAGE, + })), ...RAZER_MOUSE_DOCK_PRO_CONTROL_FILTERS, ...RAZER_VIPER_V2_CONTROL_FILTERS, ...RAZER_VIPER_V3_CONTROL_FILTERS, diff --git a/src/teevolution/index.ts b/src/teevolution/index.ts index afea060..1aaf894 100644 --- a/src/teevolution/index.ts +++ b/src/teevolution/index.ts @@ -1,5 +1,11 @@ export const TEEVOLUTION_REPORT_ID = 0x08; export const TEEVOLUTION_PACKET_LENGTH = 16; +/** RapidSync 8K LCD clock — separate HID collection from Compx report 8. */ +export const TEEVOLUTION_LCD_REPORT_ID = 0x0a; +export const TEEVOLUTION_LCD_PACKET_LENGTH = 40; +export const TEEVOLUTION_LCD_USAGE_PAGE = 0xff08; +export const TEEVOLUTION_LCD_USAGE = 0x02; +export const TEEVOLUTION_LCD_SET_TIME = 0x0010; export const TEEVOLUTION_MAX_CHUNK = 10; export const TEEVOLUTION_DPI_MAX = 42000; export const TEEVOLUTION_PRODUCT_IDS = new Set([0xf520, 0xf523, 0xf5bb, 0xf522]); @@ -355,3 +361,36 @@ export function teevolutionSensorModeUi(options: { return { mode: "Ultra", editable: false, storedValue }; } +/** + * Teevolink Generate_CRC for RapidSync LCD writes: last byte is + * `(0x55 - sum(packet[0..len-2])) & 0xff`, including the report-id byte. + */ +export function teevolutionLcdChecksum(packet: Uint8Array | readonly number[]): number { + if (packet.length < 2) throw new Error("Teevolution LCD packets need a checksum byte."); + let sum = 0; + for (let index = 0; index < packet.length - 1; index += 1) sum += packet[index]! & 0xff; + return (0x55 - (sum & 0xff)) & 0xff; +} + +/** + * 40-byte RapidSync set-time write, including report id 0x0A as byte 0. + * DateTime layout matches Teevolink GetCurrentTimer_Tick (year big-endian, + * Sunday = 0). WebHID sendReport uses bytes 1…39. + */ +export function teevolutionBuildLcdTimePacket(now: Date): Uint8Array { + const packet = new Uint8Array(TEEVOLUTION_LCD_PACKET_LENGTH); + packet[0] = TEEVOLUTION_LCD_REPORT_ID; + packet[1] = TEEVOLUTION_LCD_SET_TIME >> 8; + packet[2] = TEEVOLUTION_LCD_SET_TIME & 0xff; + packet[3] = (now.getFullYear() >> 8) & 0xff; + packet[4] = now.getFullYear() & 0xff; + packet[5] = now.getMonth() + 1; + packet[6] = now.getDate(); + packet[7] = now.getHours(); + packet[8] = now.getMinutes(); + packet[9] = now.getSeconds(); + packet[10] = now.getDay(); + packet[TEEVOLUTION_LCD_PACKET_LENGTH - 1] = teevolutionLcdChecksum(packet); + return packet; +} +