diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index 80f80a0c..fd68da44 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -5,11 +5,10 @@ const BUDGET_BYTES: Record = { // Viper V3 sleep and low-power controls add a small, device-specific row to // the existing settings UI. ".css": 46_000, - // Raised from 280 kB for the production Logitech onboard-profile codec, - // guarded flash editor, verification exporter, upstream Finalmouse driver, - // the dedicated Viper Mini protocol driver, and Viper V3 sleep/low-power plus - // asymmetric lift-off protocol and controls. Preview fixtures remain dev-only. - ".js": 325_000, + // Raised from 325 kB for the production Teevolution Compx protocol codec, + // CID capability table, verified flash driver, and staged Terra Pro controls. + // Protocol tests and preview fixtures are test/dev-only and are not bundled. + ".js": 340_000, }; const ASSETS = join("dist", "assets"); diff --git a/src/control-events.ts b/src/control-events.ts index 658a1825..ef405613 100644 --- a/src/control-events.ts +++ b/src/control-events.ts @@ -40,6 +40,9 @@ export interface ControlEventHandlers { toggleSleep(enabled: boolean): void; applyLowPowerThreshold(percent: number): void; applyPulsarToggle(setting: PulsarToggleSetting, enabled: boolean): void; + applyTeevolutionSensorMode(mode: NonNullable): void; + applyTeevolutionPerformanceDuration(duration: number): void; + applyTeevolutionDpiLighting(setting: "mode" | "brightness" | "speed", value: number): void; applyEggFilter(setting: EggFilterSetting, enabled: boolean): void; applyEggSpdtMode(button: "left" | "right", mode: EggSpdtMode): void; applyEggCpiLevels(levels: number): void; @@ -176,6 +179,26 @@ export function bindControlEvents(handlers: ControlEventHandlers): void { void handlers.applyPulsarToggle(setting, (event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true"); }); } + document.querySelector("#teevolution-sensor-mode")?.addEventListener("change", (event) => { + const mode = (event.target as HTMLSelectElement).value as NonNullable; + void handlers.applyTeevolutionSensorMode(mode); + }); + document.querySelector("#teevolution-performance-duration")?.addEventListener("change", (event) => { + void handlers.applyTeevolutionPerformanceDuration(Number((event.target as HTMLSelectElement).value)); + }); + document.querySelector("#teevolution-dpi-light-mode")?.addEventListener("change", (event) => { + handlers.applyTeevolutionDpiLighting("mode", Number((event.target as HTMLSelectElement).value)); + }); + for (const setting of ["brightness", "speed"] as const) { + const input = document.querySelector(`#teevolution-dpi-light-${setting}`); + input?.addEventListener("input", () => { + const output = document.querySelector(`#teevolution-dpi-light-${setting}-output`); + if (output) output.value = input.value; + }); + input?.addEventListener("change", () => { + handlers.applyTeevolutionDpiLighting(setting, Number(input.value)); + }); + } for (const [selector, setting] of [["#slamclick-filter-toggle", "slamclick"], ["#motion-jitter-filter-toggle", "motionJitter"]] as const) { document.querySelector(selector)?.addEventListener("click", (event) => { void handlers.applyEggFilter(setting, (event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true"); diff --git a/src/control-template.ts b/src/control-template.ts index 417bfb8b..eafabfc7 100644 --- a/src/control-template.ts +++ b/src/control-template.ts @@ -90,7 +90,8 @@ export function controlTemplate(buildLabel: string): string {

CLICK

Debounce

POWER

Auto sleep

-

SENSOR

Processing

Motion Sync
Angle snapping
Ripple control
Performance mode
+

SENSOR

Processing

Motion Sync
Angle snapping
Ripple control
Performance mode
+ diff --git a/src/control.css b/src/control.css index a4172328..d534662d 100644 --- a/src/control.css +++ b/src/control.css @@ -188,6 +188,7 @@ nav { display: grid; gap: .3rem; margin-top: 1.4rem; } .switch-row { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding: .2rem 0; color: var(--dim); font-size: .7rem; } .field-label { display: block; color: var(--faint); font-size: .62rem; } .field-label.spaced { margin-top: .35rem; } +.teevolution-dpi-light-controls { margin-top: .55rem; } .egg-collapsible, .egg-experimental { overflow: hidden; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--card); } .egg-collapsible summary, .egg-experimental summary { display: flex; align-items: center; justify-content: space-between; cursor: pointer; color: #d8d8dc; font-weight: 650; list-style: none; } diff --git a/src/control.ts b/src/control.ts index a3053887..5c27d66d 100644 --- a/src/control.ts +++ b/src/control.ts @@ -88,6 +88,7 @@ import { RazerViperMiniHidClient } from "./devices/razer/viper-mini-hid"; import { RazerViperV4ProHidClient } from "./devices/razer/viper-v4-pro-hid"; import { FinalmouseHidClient } from "./devices/finalmouse/hid"; import { TeevolutionHidClient } from "./devices/teevolution/hid"; +import { teevolutionProfileForCid, teevolutionSensorModeUi } from "./devices/teevolution/protocol.ts"; import { VgnF2HidClient } from "./devices/vgn/hid"; import { SUPPORTED_HID_FILTERS } from "./devices/vendors"; import { WLMouseHidClient } from "./devices/wlmouse/hid"; @@ -392,6 +393,9 @@ function renderControl(): void { toggleSleep: (enabled) => applyPulsarValue("sleep", enabled ? lastSleepSeconds : WLMOUSE_SLEEP_NEVER), applyLowPowerThreshold, applyPulsarToggle, + applyTeevolutionSensorMode, + applyTeevolutionPerformanceDuration, + applyTeevolutionDpiLighting, applyEggFilter, applyEggSpdtMode, applyEggCpiLevels, @@ -679,7 +683,11 @@ function sleepLabel(seconds: number): string { } function fillSleepOptions(options: ReadonlyArray): void { - const select = document.querySelector("#sleep-select"); + fillSelectOptions("#sleep-select", options); +} + +function fillSelectOptions(selector: string, options: ReadonlyArray): void { + const select = document.querySelector(selector); const signature = options.map(([value]) => value).join(","); if (!select || select.dataset.options === signature) return; select.replaceChildren(...options.map(([value, label]) => new Option(label, String(value)))); @@ -1169,6 +1177,70 @@ function showStatus(deviceStatus: MouseStatus): void { setToggleValue("#angle-snapping-toggle", status.angleSnapping); setToggleValue("#ripple-control-toggle", status.rippleControl); setToggleValue("#performance-mode-toggle", status.performanceMode); + const isTeevolution = status.brand === "Teevolution"; + const performanceModeLabel = document.querySelector("#performance-mode-label"); + if (performanceModeLabel) performanceModeLabel.textContent = isTeevolution ? "Highest performance" : "Performance mode"; + const teevolutionSensorRow = document.querySelector("#teevolution-sensor-mode-row"); + const teevolutionDurationRow = document.querySelector("#teevolution-performance-duration-row"); + const teevolutionDpiLighting = document.querySelector("#teevolution-dpi-lighting"); + if (teevolutionSensorRow) teevolutionSensorRow.hidden = !isTeevolution; + if (teevolutionDurationRow) teevolutionDurationRow.hidden = !isTeevolution; + if (teevolutionDpiLighting) teevolutionDpiLighting.hidden = !isTeevolution; + const teevolutionProfile = activeTeevolutionClient?.getModelProfile() ?? teevolutionProfileForCid(14); + if (isTeevolution && status.connectionType && teevolutionProfile) { + const profile = teevolutionProfile; + fillSleepOptions(profile.sleepOptions.map((value) => [value, sleepLabel(value * 10)] as const)); + fillDebounceOptions(profile.debounce.max); + const performanceDurations = profile.performanceTimeOptions + .map((value) => [value, sleepLabel(value * 10)] as const); + fillSelectOptions("#teevolution-performance-duration", performanceDurations); + const sensorUi = teevolutionSensorModeUi({ + storedMode: status.sensorModeStored ?? 0, + pollingRateHz: status.pollingRateHz, + connection: status.connectionType, + }); + const sensorSelect = document.querySelector("#teevolution-sensor-mode"); + if (sensorSelect) { + for (const option of sensorSelect.options) { + option.hidden = option.value !== "Ultra" + && !profile.sensorModes.includes(option.value as "Eco" | "High"); + } + sensorSelect.value = sensorUi.mode; + sensorSelect.disabled = !sensorUi.editable; + } + setText("#teevolution-sensor-mode-note", sensorUi.editable + ? "Eco saves power at 125–1000 Hz wireless. High uses the performance sensor profile." + : `Locked to ${sensorUi.mode} at ${status.pollingRateHz.toLocaleString()} Hz ${status.connectionType.toLowerCase()}.`); + const durationSelect = document.querySelector("#teevolution-performance-duration"); + if (durationSelect) { + durationSelect.disabled = status.performanceMode !== true; + setControlValue("#teevolution-performance-duration", status.performanceDuration); + } + const lightMode = status.dpiLedMode ?? 0; + const lightModeSelect = document.querySelector("#teevolution-dpi-light-mode"); + if (lightModeSelect) { + for (const option of lightModeSelect.options) { + option.hidden = !profile.dpiLighting.modes.includes(Number(option.value) as 0 | 1 | 2); + } + } + setControlValue("#teevolution-dpi-light-mode", lightMode); + setControlValue("#teevolution-dpi-light-brightness", status.dpiLedBrightness); + setControlValue("#teevolution-dpi-light-speed", status.dpiLedSpeed); + setText("#teevolution-dpi-light-brightness-output", status.dpiLedBrightness == null ? "—" : String(status.dpiLedBrightness)); + setText("#teevolution-dpi-light-speed-output", status.dpiLedSpeed == null ? "—" : String(status.dpiLedSpeed)); + const brightness = document.querySelector("#teevolution-dpi-light-brightness"); + const speed = document.querySelector("#teevolution-dpi-light-speed"); + if (brightness) { + brightness.min = String(profile.dpiLighting.brightness.min); + brightness.max = String(profile.dpiLighting.brightness.max); + brightness.disabled = lightMode !== 1 || status.dpiLedBrightness == null; + } + if (speed) { + speed.min = String(profile.dpiLighting.speed.min); + speed.max = String(profile.dpiLighting.speed.max); + speed.disabled = lightMode !== 2 || status.dpiLedSpeed == null; + } + } for (const selector of ["#angle-snapping-toggle", "#ripple-control-toggle"] as const) { const row = document.querySelector(selector)?.closest(".switch-row"); if (row) { @@ -3126,10 +3198,77 @@ function settingLabel(setting: PulsarToggleSetting): string { motionSync: "Motion Sync", angleSnapping: "angle snapping", rippleControl: "ripple control", - performanceMode: "performance mode", + performanceMode: activeTeevolutionClient ? "highest performance" : "performance mode", } as const)[setting]; } +function applyTeevolutionSensorMode(mode: NonNullable): void { + if (!activeTeevolutionClient) return; + stageChange({ + key: "teevolution-sensor-mode", + label: `Sensor mode ${mode}`, + command: `Set sensor mode to ${mode}`, + progress: `Setting sensor mode to ${mode}…`, + preview: (status) => { + status.sensorMode = mode; + status.sensorModeStored = mode === "High" ? 1 : 0; + }, + apply: async () => { + await requireClientMethod("setSensorMode", "sensor mode").setSensorMode(mode); + }, + }); +} + +function applyTeevolutionPerformanceDuration(duration: number): void { + if (!activeTeevolutionClient) return; + const label = sleepLabel(duration * 10); + stageChange({ + key: "teevolution-performance-duration", + label: `Highest performance ${label}`, + command: `Set highest-performance duration to ${label}`, + progress: "Setting highest-performance duration…", + preview: (status) => { + status.performanceDuration = duration; + }, + apply: async () => { + await requireClientMethod("setPerformanceDuration", "highest-performance duration").setPerformanceDuration(duration); + }, + }); +} + +const TEEVOLUTION_DPI_LIGHT_GROUP = "teevolution-dpi-lighting"; + +async function writeStagedTeevolutionDpiLighting(): Promise { + if (!activeTeevolutionClient || !latestDeviceStatus) { + throw new Error("The Teevolution mouse is no longer connected."); + } + const status = withPendingChanges(latestDeviceStatus); + await activeTeevolutionClient.setDpiLighting( + status.dpiLedMode ?? 0, + status.dpiLedBrightness ?? 5, + status.dpiLedSpeed ?? 3, + ); +} + +function applyTeevolutionDpiLighting(setting: "mode" | "brightness" | "speed", value: number): void { + if (!activeTeevolutionClient) return; + const names = { mode: "effect", brightness: "brightness", speed: "speed" } as const; + const display = setting === "mode" ? (["Off", "Steady", "Breathing"][value] ?? `${value}`) : `${value}`; + stageChange({ + key: `teevolution-dpi-light-${setting}`, + group: TEEVOLUTION_DPI_LIGHT_GROUP, + label: `DPI light ${names[setting]} ${display}`, + command: `Set DPI light ${names[setting]} to ${display}`, + progress: `Setting DPI light ${names[setting]}…`, + preview: (status) => { + if (setting === "mode") status.dpiLedMode = value; + if (setting === "brightness") status.dpiLedBrightness = value; + if (setting === "speed") status.dpiLedSpeed = value; + }, + apply: writeStagedTeevolutionDpiLighting, + }); +} + function applyEggFilter(setting: "slamclick" | "motionJitter", enabled: boolean): void { if (!activeEggClient) return; const label = setting === "slamclick" ? "slamclick filter" : "motion-jitter filter"; diff --git a/src/devices/mouse-types.ts b/src/devices/mouse-types.ts index 97ccfb3a..f4fe5681 100644 --- a/src/devices/mouse-types.ts +++ b/src/devices/mouse-types.ts @@ -87,6 +87,10 @@ export interface MouseStatus { eggMulticlickFilters?: number[]; eggButtonMappings?: string[]; performanceMode?: boolean | null; + sensorMode?: "Eco" | "High" | "Ultra" | null; + sensorModeStored?: 0 | 1 | null; + sensorModeEditable?: boolean | null; + performanceDuration?: number | null; angleTuning?: number | null; wheelAcceleration?: boolean | null; lowBatteryWarning?: number | null; diff --git a/src/devices/registry.test.ts b/src/devices/registry.test.ts index 42bb41ab..083340dc 100644 --- a/src/devices/registry.test.ts +++ b/src/devices/registry.test.ts @@ -150,7 +150,7 @@ test("every product id offered in the picker has a driver", () => { ); }); -const WITHOUT_TESTS = new Set(["lamzu", "pulsar", "teevolution", "wlmouse"]); +const WITHOUT_TESTS = new Set(["lamzu", "pulsar", "wlmouse"]); function deviceDirectories(): string[] { return readdirSync(DEVICES_DIR, { withFileTypes: true }) diff --git a/src/devices/teevolution/hid.test.ts b/src/devices/teevolution/hid.test.ts new file mode 100644 index 00000000..f4c22ff0 --- /dev/null +++ b/src/devices/teevolution/hid.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { TeevolutionHidClient } from "./hid.ts"; +import { TEEVOLUTION_REPORT_ID } from "./protocol.ts"; + +function device(productId: number, reportId = TEEVOLUTION_REPORT_ID): HIDDevice { + return { + vendorId: 0x3554, + productId, + productName: "RapidSync", + collections: [{ + usagePage: 0xff00, + usage: 1, + children: [], + featureReports: [], + inputReports: [{ reportId, items: [{ reportCount: 16, reportSize: 8 }] }], + outputReports: [{ reportId, items: [{ reportCount: 16, reportSize: 8 }] }], + }], + } as unknown as HIDDevice; +} + +test("support is limited to Terra Pro Compx transports with report 8", () => { + // Arrange + const receiver = device(0xf523); + const wired = device(0xf520); + const alt = device(0xf5bb); + const wrongPid = device(0xfb56); + const wrongReport = device(0xf523, 0x09); + + // Act / Assert + assert.equal(TeevolutionHidClient.isSupported(receiver), true); + assert.equal(TeevolutionHidClient.isSupported(wired), true); + assert.equal(TeevolutionHidClient.isSupported(alt), true); + assert.equal(TeevolutionHidClient.isSupported(device(0xf522)), true); + assert.equal(TeevolutionHidClient.isSupported(wrongPid), false); + assert.equal(TeevolutionHidClient.isSupported(wrongReport), false); +}); diff --git a/src/devices/teevolution/hid.ts b/src/devices/teevolution/hid.ts index c760df6d..059e81dc 100644 --- a/src/devices/teevolution/hid.ts +++ b/src/devices/teevolution/hid.ts @@ -1,48 +1,35 @@ import type { MouseStatus } from "../mouse-types.ts"; import { VENDOR_ID } from "../vendors.ts"; - -const CONFIG_REPORT_ID = 0x08; -const CONFIG_PACKET_LENGTH = 16; -const PRODUCT_IDS = new Set([0xf520, 0xf523, 0xf5bb, 0xf522]); -const TERRA_PRO_CID = 14; -const DPI_MAX = 42000; -const POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000] as const; -const SLEEP_OPTIONS = [1, 3, 6, 12, 30, 60, 180] as const; - -const COMMAND = { - encryptionData: 0x01, - deviceOnline: 0x03, - batteryLevel: 0x04, - writeFlashData: 0x07, - readFlashData: 0x08, - getCurrentConfig: 0x0e, - readVersionId: 0x12, - getDongleVersion: 0x1d, - getRssi: 0x2b, -} as const; - -const FLASH = { - reportRate: 0, - currentDpi: 4, - liftOffDistance: 10, - dpiValues: 12, - debounceTime: 169, - motionSync: 171, - sleepTime: 173, - angleSnapping: 175, - rippleControl: 177, - performanceState: 181, - performanceTime: 183, -} as const; - -const TYPE_MAX_POLL: Record = { - 0: 1000, - 1: 4000, - 2: 1000, - 3: 8000, - 4: 2000, - 5: 8000, -}; +import { + TEEVOLUTION_COMMAND, + TEEVOLUTION_FLASH, + TEEVOLUTION_PRODUCT_IDS, + TEEVOLUTION_REPORT_ID, + TEEVOLUTION_TYPE_MAX_POLL, + teevolutionBuildOnlinePayload, + teevolutionBuildReadPayload, + teevolutionBuildSimplePayload, + teevolutionBuildWritePayload, + teevolutionDecodeDpiLightBrightness, + teevolutionDecodeDpiLightMode, + teevolutionDecodeDpi, + teevolutionDecodeFirmwareVersion, + teevolutionDecodeLiftOff, + teevolutionDecodePollingRate, + teevolutionDecodeSensorModeStored, + teevolutionDpiOptions, + teevolutionEncodeDpiLightBrightness, + teevolutionEncodeDpi, + teevolutionEncodeLiftOff, + teevolutionEncodePollingRate, + teevolutionEncodeSensorMode, + teevolutionPacketChecksum, + teevolutionParseBattery, + teevolutionParseReadResponse, + teevolutionProfileForCid, + teevolutionSensorModeUi, + type TeevolutionDeviceProfile, +} from "./protocol.ts"; export interface TeevolutionDeviceInfo { cid: number; @@ -51,6 +38,7 @@ export interface TeevolutionDeviceInfo { dongleType: number; connection: "Wired" | "Wireless"; maximumPollingRateHz: number; + profile: TeevolutionDeviceProfile; } export class TeevolutionHidClient { @@ -65,7 +53,7 @@ export class TeevolutionHidClient { const bytes = new Uint8Array( event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), ); - if (event.reportId === CONFIG_REPORT_ID && bytes[0] === this.responseWaiter?.command) { + if (event.reportId === TEEVOLUTION_REPORT_ID && bytes[0] === this.responseWaiter?.command) { const waiter = this.responseWaiter; this.responseWaiter = null; waiter.resolve(bytes); @@ -80,12 +68,12 @@ export class TeevolutionHidClient { static isSupported(device: HIDDevice): boolean { return device.vendorId === VENDOR_ID.teevolution - && PRODUCT_IDS.has(device.productId) + && TEEVOLUTION_PRODUCT_IDS.has(device.productId) && device.collections.some((collection) => collection.inputReports.length === 1 && collection.outputReports.length === 1 - && collection.inputReports[0]?.reportId === CONFIG_REPORT_ID - && collection.outputReports[0]?.reportId === CONFIG_REPORT_ID); + && collection.inputReports[0]?.reportId === TEEVOLUTION_REPORT_ID + && collection.outputReports[0]?.reportId === TEEVOLUTION_REPORT_ID); } async open(): Promise { @@ -112,16 +100,22 @@ export class TeevolutionHidClient { const challenge = new Uint8Array(8); crypto.getRandomValues(challenge); challenge.fill(0, 4); - const response = await this.query(COMMAND.encryptionData, challenge); + const response = await this.query(TEEVOLUTION_COMMAND.encryptionData, challenge); this.assertAccepted(response, "identification"); + const cid = response[9] ?? 0; + const profile = teevolutionProfileForCid(cid); + if (!profile) { + throw new Error(`Unsupported Teevolution model (CID 0x${cid.toString(16).padStart(2, "0")}). No flash settings were read or written.`); + } const type = response[11] ?? 0xff; this.deviceInfo = { - cid: response[9] ?? 0, + cid, mid: response[10] ?? 0, type, dongleType: response[12] ?? 0, connection: type === 2 || type === 3 ? "Wired" : "Wireless", - maximumPollingRateHz: TYPE_MAX_POLL[type] ?? 1000, + maximumPollingRateHz: TEEVOLUTION_TYPE_MAX_POLL[type] ?? 1000, + profile, }; return this.deviceInfo; } @@ -129,39 +123,63 @@ export class TeevolutionHidClient { async readStatus(): Promise { const info = this.deviceInfo ?? await this.readDeviceInfo(); return await this.withDeviceControl(async () => { - const flash = await this.readFlash(FLASH.reportRate, FLASH.performanceTime + 2); - const battery = await this.query(COMMAND.batteryLevel); - const deviceVersion = await this.query(COMMAND.readVersionId); - const dongleVersion = await this.query(COMMAND.getDongleVersion).catch(() => null); - const profile = await this.query(COMMAND.getCurrentConfig).catch(() => null); - const rssi = await this.query(COMMAND.getRssi).catch(() => null); - const currentDpi = Math.min(flash[FLASH.currentDpi] ?? 0, 7); - const dpi = this.decodeDpi(flash.slice(FLASH.dpiValues + currentDpi * 4, FLASH.dpiValues + currentDpi * 4 + 4)); - const lodValue = flash[FLASH.liftOffDistance]; - const pollingRateHz = this.decodePollingRate(flash[FLASH.reportRate] ?? 1); + 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 currentDpi = Math.min( + flash[TEEVOLUTION_FLASH.currentDpi] ?? 0, + info.profile.dpiStageCount - 1, + ); + const dpi = teevolutionDecodeDpi( + flash.slice(TEEVOLUTION_FLASH.dpiValues + currentDpi * 4, TEEVOLUTION_FLASH.dpiValues + currentDpi * 4 + 4), + ); + 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: "Terra Pro", + defaultDisplayName: info.profile.name, hideUnsupportedPollingRates: true, + hideSignalCard: true, }, - batteryPercent: (battery[5] ?? 0xff) <= 100 ? battery[5]! : null, - batteryState: battery[6] === 1 ? "Charging" : "Discharging", + batteryPercent: battery?.percent ?? null, + batteryState: battery?.charging ? "Charging" : "Discharging", dpi, pollingRateHz, - supportedPollingRates: POLLING_RATES.filter((rate) => rate <= info.maximumPollingRateHz), + 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, - debounceMs: flash[FLASH.debounceTime] ?? null, - motionSync: flash[FLASH.motionSync] === 1, - sleepTimeout: flash[FLASH.sleepTime] ?? null, - angleSnapping: flash[FLASH.angleSnapping] === 1, - rippleControl: flash[FLASH.rippleControl] === 1, - performanceMode: flash[FLASH.performanceState] === 1, - liftOffDistance: lodValue === 3 ? "Low" : lodValue === 1 ? "Medium" : lodValue === 2 ? "High" : 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", @@ -171,85 +189,165 @@ export class TeevolutionHidClient { } getDpiOptions(): number[] { - const options: number[] = []; - for (let dpi = 50; dpi <= 30000; dpi += 50) options.push(dpi); - for (let dpi = 30100; dpi <= DPI_MAX; dpi += 100) options.push(dpi); - return options; + return teevolutionDpiOptions(this.profile()); + } + + getModelProfile(): TeevolutionDeviceProfile { + return this.profile(); } async setPollingRate(pollingRateHz: number): Promise { - const encoded = pollingRateHz <= 1000 ? 1000 / pollingRateHz : pollingRateHz / 2000 * 16; - if (!Number.isInteger(encoded) || !(POLLING_RATES as readonly number[]).includes(pollingRateHz)) { - throw new Error("Unsupported Teevolution polling rate."); - } const info = this.deviceInfo ?? await this.readDeviceInfo(); + if (!info.profile.pollingRates.includes(pollingRateHz)) { + throw new Error(`${pollingRateHz} Hz is not supported by the ${info.profile.name}.`); + } + const encoded = teevolutionEncodePollingRate(pollingRateHz); if (pollingRateHz > info.maximumPollingRateHz) { throw new Error(`This connection supports at most ${info.maximumPollingRateHz} Hz.`); } return await this.withDeviceControl(async () => { - await this.writeCheckedByte(FLASH.reportRate, encoded); - const confirmed = this.decodePollingRate((await this.readFlash(FLASH.reportRate, 2))[0] ?? 1); + await this.writeCheckedByte(TEEVOLUTION_FLASH.reportRate, encoded); + const confirmed = teevolutionDecodePollingRate((await this.readFlash(TEEVOLUTION_FLASH.reportRate, 2))[0] ?? 1); if (confirmed !== pollingRateHz) throw new Error(`The mouse kept ${confirmed} Hz instead of ${pollingRateHz} Hz.`); return confirmed; }); } async setDpi(dpi: number): Promise { - if (!this.getDpiOptions().includes(dpi)) throw new Error(`${dpi} DPI is not supported by the Terra Pro.`); + const info = this.deviceInfo ?? await this.readDeviceInfo(); + if (!teevolutionDpiOptions(info.profile).includes(dpi)) { + throw new Error(`${dpi} DPI is not supported by the ${info.profile.name}.`); + } return await this.withDeviceControl(async () => { - const currentDpi = (await this.readFlash(FLASH.currentDpi, 2))[0] ?? 0; - const address = FLASH.dpiValues + Math.min(currentDpi, 7) * 4; - await this.writeFlash(address, this.encodeDpi(dpi)); - const confirmed = this.decodeDpi(await this.readFlash(address, 4)); + const currentDpi = (await this.readFlash(TEEVOLUTION_FLASH.currentDpi, 2))[0] ?? 0; + const address = TEEVOLUTION_FLASH.dpiValues + + Math.min(currentDpi, info.profile.dpiStageCount - 1) * 4; + await this.writeFlash(address, teevolutionEncodeDpi(dpi)); + const confirmed = teevolutionDecodeDpi(await this.readFlash(address, 4)); if (confirmed !== dpi) throw new Error(`The mouse kept ${confirmed} DPI instead of ${dpi} DPI.`); return confirmed; }); } async setLiftOffDistance(liftOffDistance: NonNullable): Promise> { - const encoded = ({ Low: 3, Medium: 1, High: 2 } as const)[liftOffDistance]; + const profile = this.profile(); + if (!profile.liftOffDistances.includes(liftOffDistance)) { + throw new Error(`${liftOffDistance} lift-off distance is not supported by the ${profile.name}.`); + } + const encoded = teevolutionEncodeLiftOff(liftOffDistance); return await this.withDeviceControl(async () => { - await this.writeCheckedByte(FLASH.liftOffDistance, encoded); - const confirmedValue = (await this.readFlash(FLASH.liftOffDistance, 2))[0]; - const confirmed = confirmedValue === 3 ? "Low" : confirmedValue === 1 ? "Medium" : confirmedValue === 2 ? "High" : null; + await this.writeCheckedByte(TEEVOLUTION_FLASH.liftOffDistance, encoded); + const confirmed = teevolutionDecodeLiftOff((await this.readFlash(TEEVOLUTION_FLASH.liftOffDistance, 2))[0] ?? -1); if (confirmed !== liftOffDistance) throw new Error(`The mouse kept ${confirmed ?? "an unknown LOD"} instead of ${liftOffDistance}.`); return confirmed; }); } async setMotionSync(enabled: boolean): Promise { - return await this.setVerifiedBoolean(FLASH.motionSync, enabled, "Motion Sync"); + return await this.setVerifiedBoolean(TEEVOLUTION_FLASH.motionSync, enabled, "Motion Sync"); } async setAngleSnapping(enabled: boolean): Promise { - return await this.setVerifiedBoolean(FLASH.angleSnapping, enabled, "angle snapping"); + return await this.setVerifiedBoolean(TEEVOLUTION_FLASH.angleSnapping, enabled, "angle snapping"); } async setRippleControl(enabled: boolean): Promise { - return await this.setVerifiedBoolean(FLASH.rippleControl, enabled, "ripple control"); + return await this.setVerifiedBoolean(TEEVOLUTION_FLASH.rippleControl, enabled, "ripple control"); + } + + getPerformanceDurationOptions(): number[] { + return [...this.profile().performanceTimeOptions]; + } + + async setSensorMode(mode: NonNullable): Promise> { + const info = this.deviceInfo ?? await this.readDeviceInfo(); + if (mode === "Ultra" || !info.profile.sensorModes.includes(mode)) { + throw new Error(`${mode} sensor mode cannot be written on the ${info.profile.name}.`); + } + const pollingRateHz = teevolutionDecodePollingRate( + (await this.readFlash(TEEVOLUTION_FLASH.reportRate, 2))[0] ?? 1, + ); + const ui = teevolutionSensorModeUi({ + storedMode: teevolutionEncodeSensorMode(mode), + pollingRateHz, + connection: info.connection, + }); + if (!ui.editable) { + throw new Error(`Sensor mode cannot be changed at ${pollingRateHz.toLocaleString()} Hz over ${info.connection.toLowerCase()}.`); + } + return await this.withDeviceControl(async () => { + const encoded = teevolutionEncodeSensorMode(mode); + await this.writeCheckedByte(TEEVOLUTION_FLASH.sensorMode, encoded); + const confirmed = teevolutionDecodeSensorModeStored((await this.readFlash(TEEVOLUTION_FLASH.sensorMode, 2))[0] ?? -1); + if (confirmed !== mode) throw new Error(`The mouse kept ${confirmed} sensor mode instead of ${mode}.`); + return confirmed; + }); + } + + async setPerformanceDuration(duration: number): Promise { + if (!this.profile().performanceTimeOptions.includes(duration)) { + throw new Error("Unsupported Teevolution highest-performance duration."); + } + return await this.setVerifiedByte(TEEVOLUTION_FLASH.performanceTime, duration, "highest-performance duration"); } async setPerformanceMode(enabled: boolean): Promise { - return await this.setVerifiedBoolean(FLASH.performanceState, enabled, "performance mode"); + return await this.setVerifiedBoolean(TEEVOLUTION_FLASH.performanceState, enabled, "performance mode"); + } + + async setDpiLighting(mode: number, brightness: number, speed: number): Promise { + const lighting = this.profile().dpiLighting; + if (!lighting.modes.includes(mode as 0 | 1 | 2)) { + throw new Error("Unsupported Teevolution DPI light effect."); + } + if (brightness < lighting.brightness.min || brightness > lighting.brightness.max) { + throw new Error(`Teevolution DPI light brightness must be from ${lighting.brightness.min} to ${lighting.brightness.max}.`); + } + const encodedBrightness = teevolutionEncodeDpiLightBrightness(brightness); + if (!Number.isInteger(speed) || speed < lighting.speed.min || speed > lighting.speed.max) { + throw new Error(`Teevolution DPI light speed must be from ${lighting.speed.min} to ${lighting.speed.max}.`); + } + await this.withDeviceControl(async () => { + const current = await this.readFlash(TEEVOLUTION_FLASH.dpiLightMode, 8); + if (mode !== 0 && current[0] !== mode) { + await this.writeCheckedByte(TEEVOLUTION_FLASH.dpiLightMode, mode); + } + if (current[2] !== encodedBrightness) { + await this.writeCheckedByte(TEEVOLUTION_FLASH.dpiLightBrightness, encodedBrightness); + } + if (current[4] !== speed) { + await this.writeCheckedByte(TEEVOLUTION_FLASH.dpiLightSpeed, speed); + } + const state = mode === 0 ? 0 : 1; + if (current[6] !== state) { + await this.writeCheckedByte(TEEVOLUTION_FLASH.dpiLightState, state); + } + + const confirmed = await this.readFlash(TEEVOLUTION_FLASH.dpiLightMode, 8); + const confirmedMode = teevolutionDecodeDpiLightMode(confirmed[0] ?? 1, confirmed[6] ?? 0); + const confirmedBrightness = teevolutionDecodeDpiLightBrightness(confirmed[2] ?? 0x80); + if (confirmedMode !== mode || confirmedBrightness !== brightness || confirmed[4] !== speed) { + throw new Error("The mouse did not confirm the requested DPI lighting settings."); + } + }); } async setDebounceTime(debounceMs: number): Promise { - if (!Number.isInteger(debounceMs) || debounceMs < 0 || debounceMs > 20) { - throw new Error("This Teevolution model supports a debounce time from 0 to 20 ms."); + const { min, max } = this.profile().debounce; + if (!Number.isInteger(debounceMs) || debounceMs < min || debounceMs > max) { + throw new Error(`This Teevolution model supports a debounce time from ${min} to ${max} ms.`); } - return await this.setVerifiedByte(FLASH.debounceTime, debounceMs, "debounce time"); + return await this.setVerifiedByte(TEEVOLUTION_FLASH.debounceTime, debounceMs, "debounce time"); } async setSleepTimeout(timeout: number): Promise { - if (!(SLEEP_OPTIONS as readonly number[]).includes(timeout)) { + if (!this.profile().sleepOptions.includes(timeout)) { throw new Error("Unsupported Teevolution sleep timeout."); } return await this.withDeviceControl(async () => { - await this.writeCheckedByte(FLASH.sleepTime, timeout); - await this.writeCheckedByte(FLASH.performanceTime, timeout); - const sleepConfirmed = (await this.readFlash(FLASH.sleepTime, 2))[0]; - const performanceConfirmed = (await this.readFlash(FLASH.performanceTime, 2))[0]; - if (sleepConfirmed !== timeout || performanceConfirmed !== timeout) { + await this.writeCheckedByte(TEEVOLUTION_FLASH.sleepTime, timeout); + const sleepConfirmed = (await this.readFlash(TEEVOLUTION_FLASH.sleepTime, 2))[0]; + if (sleepConfirmed !== timeout) { throw new Error("The mouse did not confirm the requested sleep timeout."); } return sleepConfirmed!; @@ -264,8 +362,14 @@ export class TeevolutionHidClient { } private displayName(info: TeevolutionDeviceInfo): string { - if (info.cid === TERRA_PRO_CID) return this.device.productName || "Terra Pro"; - return this.device.productName || "Teevolution Mouse"; + return info.profile.name; + } + + private profile(): TeevolutionDeviceProfile { + if (!this.deviceInfo) { + throw new Error("Read the Teevolution device identity before requesting model capabilities."); + } + return this.deviceInfo.profile; } private async withDeviceControl(operation: () => Promise): Promise { @@ -280,10 +384,7 @@ export class TeevolutionHidClient { private async setDeviceOnline(enabled: boolean): Promise { let response: Uint8Array | null = null; for (let attempt = 0; attempt < 20; attempt += 1) { - const packet = this.createPacket(COMMAND.deviceOnline); - packet[5] = enabled ? 1 : 0; - packet[15] = this.packetChecksum(packet); - response = await this.exchange(packet); + response = await this.exchange(teevolutionBuildOnlinePayload(enabled)); this.assertAccepted(response, enabled ? "host-control entry" : "host-control exit"); if (response[9] !== 1) break; await new Promise((resolve) => window.setTimeout(resolve, 10)); @@ -296,15 +397,13 @@ export class TeevolutionHidClient { const result = new Uint8Array(length); for (let offset = 0; offset < length; offset += 10) { const count = Math.min(10, length - offset); - const packet = this.createPacket(COMMAND.readFlashData); - const currentAddress = address + offset; - packet[2] = currentAddress >> 8; - packet[3] = currentAddress & 0xff; - packet[4] = count; - packet[15] = this.packetChecksum(packet); - const response = await this.exchange(packet); - this.assertAccepted(response, "configuration read"); - result.set(response.slice(5, 5 + count), offset); + const response = await this.exchange(teevolutionBuildReadPayload(address + offset, count)); + const chunk = teevolutionParseReadResponse(response, address + offset, count); + if (!chunk) { + this.assertAccepted(response, "configuration read"); + throw new Error("The Teevolution configuration read response was corrupt."); + } + result.set(chunk, offset); } return result; } @@ -328,28 +427,24 @@ export class TeevolutionHidClient { private async writeFlash(address: number, data: Uint8Array): Promise { for (let offset = 0; offset < data.length; offset += 10) { - const chunk = data.slice(offset, offset + 10); - const packet = this.createPacket(COMMAND.writeFlashData); - const currentAddress = address + offset; - packet[2] = currentAddress >> 8; - packet[3] = currentAddress & 0xff; - packet[4] = chunk.length; - packet.set(chunk, 5); - packet[15] = this.packetChecksum(packet); - this.assertAccepted(await this.exchange(packet), "configuration write"); + const chunk = [...data.slice(offset, offset + 10)]; + this.assertAccepted( + await this.exchange(teevolutionBuildWritePayload(address + offset, chunk)), + "configuration write", + ); } } private async query(command: number, parameters = new Uint8Array()): Promise { if (parameters.length > 10) throw new Error("Teevolution queries support at most 10 parameter bytes."); - const packet = this.createPacket(command); + const packet = teevolutionBuildSimplePayload(command); packet[4] = parameters.length; packet.set(parameters, 5); - packet[15] = this.packetChecksum(packet); + packet[15] = teevolutionPacketChecksum(packet); return await this.exchange(packet); } - private async exchange(packet: Uint8Array): Promise { + private async exchange(packet: Uint8Array): Promise { if (this.responseWaiter) throw new Error("Another Teevolution request is already in progress."); const command = packet[0]!; let timeout = 0; @@ -374,7 +469,7 @@ export class TeevolutionHidClient { }); void response.catch(() => undefined); try { - await this.device.sendReport(CONFIG_REPORT_ID, packet); + await this.device.sendReport(TEEVOLUTION_REPORT_ID, new Uint8Array(packet)); } catch (error) { const detail = error instanceof Error ? error.message : String(error); (rejectResponse as ((reason: Error) => void) | null)?.(new Error(`Chrome could not write Teevolution report 8. ${detail}`)); @@ -383,65 +478,10 @@ export class TeevolutionHidClient { return await response; } - private createPacket(command: number): Uint8Array { - const packet = new Uint8Array(CONFIG_PACKET_LENGTH); - packet[0] = command; - return packet; - } - - private packetChecksum(packet: Uint8Array): number { - let sum = 0; - for (let index = 0; index < packet.length - 1; index += 1) sum += packet[index]!; - return (0x55 - (sum & 0xff) - CONFIG_REPORT_ID) & 0xff; - } - - private dataChecksum(data: Uint8Array): number { - let sum = 0; - for (const value of data) sum += value; - return (0x55 - (sum & 0xff)) & 0xff; - } - - private decodePollingRate(encoded: number): number { - return encoded >= 16 ? encoded / 16 * 2000 : 1000 / encoded; - } - - /** PAW3950 Compx packing used by Teevolink (step 50, high range via dpiEx 0x11). */ - private decodeDpi(data: Uint8Array): number { - const flags = data[2] ?? 0; - const raw = (data[0] ?? 0) + (((flags & 0x0c) >> 2) << 8); - const dpiEx = flags & 0x03; - let dpi = (raw + 1) * 50; - if ((dpiEx & 0x01) !== 0) dpi *= 2; - if ((dpiEx & 0x02) !== 0) dpi *= 2; - return dpi; - } - - private encodeDpi(dpi: number): Uint8Array { - let dpiEx = 0; - let scaled = dpi; - if (dpi >= 30100) { - dpiEx = 0x11; - scaled = dpi / 2; - } - const raw = scaled / 50 - 1; - if (!Number.isInteger(raw) || raw < 0) throw new Error(`${dpi} DPI cannot be encoded for the Terra Pro.`); - const high = raw >> 8; - const result = new Uint8Array(4); - result[0] = raw & 0xff; - result[1] = raw & 0xff; - result[2] = (high << 2) | (high << 6) | dpiEx | (dpiEx << 4); - result[3] = this.dataChecksum(result.slice(0, 3)); - return result; - } - - private decodeVersion(label: string, response: Uint8Array): string { - this.assertAccepted(response, `${label.toLowerCase()} firmware read`); - return `${label} v${response[5] ?? 0}.${(response[6] ?? 0).toString(16).padStart(2, "0")}`; - } - private decodeVersionOptional(label: string, response: Uint8Array | null): string | null { if (!response || response[1] !== 0) return null; - return this.decodeVersion(label, response); + this.assertAccepted(response, `${label.toLowerCase()} firmware read`); + return teevolutionDecodeFirmwareVersion(label, response); } private assertAccepted(response: Uint8Array, operation: string): void { diff --git a/src/devices/teevolution/protocol.test.ts b/src/devices/teevolution/protocol.test.ts new file mode 100644 index 00000000..fa0047a8 --- /dev/null +++ b/src/devices/teevolution/protocol.test.ts @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + TEEVOLUTION_FLASH, + TEEVOLUTION_REPORT_ID, + teevolutionProfileForCid, + teevolutionBuildOnlinePayload, + teevolutionBuildReadPayload, + teevolutionBuildSimplePayload, + teevolutionBuildWriteScalarPayload, + teevolutionDecodeDpiLightBrightness, + teevolutionDecodeDpiLightMode, + teevolutionDecodeDpi, + teevolutionDecodeFirmwareVersion, + teevolutionDecodeLiftOff, + teevolutionDecodePollingRate, + teevolutionDpiOptions, + teevolutionEncodeDpiLightBrightness, + teevolutionEncodeDpi, + teevolutionEncodeLiftOff, + teevolutionEncodePollingRate, + teevolutionEncodeSensorMode, + teevolutionPacketChecksumIsValid, + teevolutionParseBattery, + teevolutionParseReadResponse, + teevolutionSensorModeUi, +} from "./protocol.ts"; + +test("host-control and battery commands match Compx report-8 checksums", () => { + // Arrange — same framing Teevolink uses on report ID 0x08 + const requests = [ + teevolutionBuildOnlinePayload(true), + 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.equal(TEEVOLUTION_REPORT_ID, 0x08); +}); + +test("receiver responses pass checksum validation", () => { + // Arrange — battery response shape from Compx HUB (percent + charging flag) + const response = new Uint8Array([0x04, 0, 0, 0, 2, 0x64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + response[15] = (0x55 - TEEVOLUTION_REPORT_ID - response.slice(0, 15).reduce((sum, byte) => sum + byte, 0)) & 0xff; + + // Act / Assert + assert.equal(teevolutionPacketChecksumIsValid(response), true); + response[5] ^= 1; + assert.equal(teevolutionPacketChecksumIsValid(response), false); +}); + +test("battery responses decode percent and charging", () => { + // Arrange + const response = new Uint8Array([0x04, 0, 0, 0, 2, 0x5f, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + response[15] = (0x55 - TEEVOLUTION_REPORT_ID - response.slice(0, 15).reduce((sum, byte) => sum + byte, 0)) & 0xff; + + // Act + const battery = teevolutionParseBattery(response); + + // Assert + assert.deepEqual(battery, { percent: 95, charging: true }); +}); + +test("flash reads reject corrupt or mismatched responses", () => { + // Arrange + const response = new Uint8Array([0x08, 0, 0, 0x0a, 4, 1, 0x54, 0x1f, 0x1f, 0, 0, 0, 0, 0, 0, 0]); + response[15] = (0x55 - TEEVOLUTION_REPORT_ID - response.slice(0, 15).reduce((sum, byte) => sum + byte, 0)) & 0xff; + + // Act / Assert + assert.deepEqual([...teevolutionParseReadResponse(response, 0x000a, 4)!], [1, 0x54, 0x1f, 0x1f]); + assert.equal(teevolutionParseReadResponse(response, 0x000b, 4), null); + response[15] ^= 1; + assert.equal(teevolutionParseReadResponse(response, 0x000a, 4), null); +}); + +test("DPI and polling rate codecs round-trip Terra Pro UI values", () => { + // Arrange — includes the Teevolink high-DPI range above 30,100 + const dpis = [50, 1600, 30000, 30100, 42000]; + const rates = [125, 250, 500, 1000, 2000, 4000, 8000]; + + // Act / Assert + for (const dpi of dpis) assert.equal(teevolutionDecodeDpi(teevolutionEncodeDpi(dpi)), dpi); + for (const rate of rates) { + assert.equal(teevolutionDecodePollingRate(teevolutionEncodePollingRate(rate)), rate); + } +}); + +test("high-DPI packing uses dpiEx 0x11 like Teevolink", () => { + // Arrange / Act + const stage = teevolutionEncodeDpi(30100); + + // Assert — low nibble of flags carries the extend bits used on decode + assert.equal(stage[2]! & 0x03, 0x01); + assert.equal(stage[3], (0x55 - stage[0]! - stage[1]! - stage[2]!) & 0xff); + assert.equal(teevolutionDecodeDpi(stage), 30100); +}); + +test("scalar writes include the value parity byte", () => { + // Arrange / Act + const payload = teevolutionBuildWriteScalarPayload(TEEVOLUTION_FLASH.motionSync, 1); + + // Assert + assert.deepEqual([...payload.slice(0, 7)], [0x07, 0, 0, 0xab, 2, 1, 0x54]); + assert.equal(teevolutionPacketChecksumIsValid(payload), true); +}); + +test("lift-off codes match Teevolink Low/Medium/High mapping", () => { + assert.equal(teevolutionEncodeLiftOff("Low"), 3); + assert.equal(teevolutionEncodeLiftOff("Medium"), 1); + assert.equal(teevolutionEncodeLiftOff("High"), 2); + assert.equal(teevolutionDecodeLiftOff(3), "Low"); + assert.equal(teevolutionDecodeLiftOff(1), "Medium"); + assert.equal(teevolutionDecodeLiftOff(2), "High"); + assert.equal(teevolutionDecodeLiftOff(0), null); +}); + +test("firmware version strings match Teevolink formatting", () => { + const response = new Uint8Array([0x12, 0, 0, 0, 2, 1, 0x2a, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + assert.equal(teevolutionDecodeFirmwareVersion("Mouse", response), "Mouse v1.2a"); +}); + +test("DPI option list covers 50–30k then 100-step high range", () => { + const profile = teevolutionProfileForCid(14); + if (!profile) throw new Error("Terra Pro capability profile is missing."); + const options = teevolutionDpiOptions(profile); + assert.equal(options[0], 50); + assert.ok(options.includes(30000)); + assert.ok(options.includes(30100)); + assert.equal(options.at(-1), 42000); + assert.equal(options.filter((dpi) => dpi > 30000 && dpi % 100 !== 0).length, 0); +}); + +test("Terra Pro capabilities are selected by reported CID", () => { + const profile = teevolutionProfileForCid(14); + if (!profile) throw new Error("Terra Pro capability profile is missing."); + assert.equal(profile.name, "Terra Pro"); + assert.equal(profile.protocol, "compX-terra-v1"); + assert.equal(profile.dpiStageCount, 4); + assert.deepEqual(profile.pollingRates, [125, 250, 500, 1000, 2000, 4000, 8000]); + assert.deepEqual(profile.liftOffDistances, ["Low", "Medium", "High"]); + assert.deepEqual(profile.sensorModes, ["Eco", "High"]); + assert.equal(teevolutionProfileForCid(0xff), null); +}); + +test("DPI light brightness matches Teevolink's nonlinear 1–10 table", () => { + const stored = [0x10, 0x1e, 0x3c, 0x5a, 0x80, 0x96, 0xb4, 0xd2, 0xe6, 0xff]; + for (let level = 1; level <= 10; level += 1) { + assert.equal(teevolutionEncodeDpiLightBrightness(level), stored[level - 1]); + assert.equal(teevolutionDecodeDpiLightBrightness(stored[level - 1]!), level); + } + assert.equal(teevolutionDecodeDpiLightBrightness(0), 5); + assert.throws(() => teevolutionEncodeDpiLightBrightness(0), /1 to 10/); +}); + +test("DPI light state exposes Off, Steady, and Breathing", () => { + assert.equal(teevolutionDecodeDpiLightMode(1, 0), 0); + assert.equal(teevolutionDecodeDpiLightMode(1, 1), 1); + assert.equal(teevolutionDecodeDpiLightMode(2, 1), 2); + assert.equal(teevolutionDecodeDpiLightMode(99, 1), 1); +}); + +test("sensor mode UI matches Teevolink Eco/High/Ultra rules", () => { + assert.deepEqual(teevolutionSensorModeUi({ + storedMode: 0, + pollingRateHz: 1000, + connection: "Wireless", + }), { mode: "Eco", editable: true, storedValue: 0 }); + assert.deepEqual(teevolutionSensorModeUi({ + storedMode: 1, + pollingRateHz: 500, + connection: "Wireless", + }), { mode: "High", editable: true, storedValue: 1 }); + assert.deepEqual(teevolutionSensorModeUi({ + storedMode: 0, + pollingRateHz: 4000, + connection: "Wireless", + }), { mode: "Ultra", editable: false, storedValue: 0 }); + assert.deepEqual(teevolutionSensorModeUi({ + storedMode: 1, + pollingRateHz: 1000, + connection: "Wired", + }), { mode: "Ultra", editable: false, storedValue: 1 }); +}); + +test("sensor mode encoding accepts Eco and High only", () => { + assert.equal(teevolutionEncodeSensorMode("Eco"), 0); + assert.equal(teevolutionEncodeSensorMode("High"), 1); + assert.throws(() => teevolutionEncodeSensorMode("Ultra"), /cannot be written/); +}); diff --git a/src/devices/teevolution/protocol.ts b/src/devices/teevolution/protocol.ts new file mode 100644 index 00000000..b1cbb4a9 --- /dev/null +++ b/src/devices/teevolution/protocol.ts @@ -0,0 +1,352 @@ +export const TEEVOLUTION_REPORT_ID = 0x08; +export const TEEVOLUTION_PACKET_LENGTH = 16; +export const TEEVOLUTION_MAX_CHUNK = 10; +export const TEEVOLUTION_DPI_MAX = 42000; +export const TEEVOLUTION_PRODUCT_IDS = new Set([0xf520, 0xf523, 0xf5bb, 0xf522]); +export const TEEVOLUTION_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000] as const; +export const TEEVOLUTION_SLEEP_OPTIONS = [1, 3, 6, 12, 30, 60, 180] as const; +/** Teevolink "Highest performance" duration codes (flash 0xB7). */ +export const TEEVOLUTION_PERFORMANCE_TIME_OPTIONS = [1, 3, 6, 12, 30, 60, 90] as const; +export const TEEVOLUTION_PERFORMANCE_TIME_LABELS: ReadonlyArray = [ + [1, "10 seconds"], [3, "30 seconds"], [6, "1 minute"], [12, "2 minutes"], + [30, "5 minutes"], [60, "10 minutes"], [90, "15 minutes"], +]; +export const TEEVOLUTION_DPI_LIGHT_MODES = [0, 1, 2] as const; +export type TeevolutionDpiLightMode = typeof TEEVOLUTION_DPI_LIGHT_MODES[number]; +export type TeevolutionSensorMode = "Eco" | "High" | "Ultra"; +export type TeevolutionLiftOffDistance = "Low" | "Medium" | "High"; + +export interface TeevolutionDeviceProfile { + cid: number; + name: string; + protocol: "compX-terra-v1"; + dpiStageCount: number; + dpi: { + min: number; + standardMax: number; + max: number; + standardStep: number; + highStep: number; + }; + pollingRates: readonly number[]; + liftOffDistances: readonly TeevolutionLiftOffDistance[]; + debounce: { min: number; max: number }; + sleepOptions: readonly number[]; + performanceTimeOptions: readonly number[]; + sensorModes: readonly Exclude[]; + dpiLighting: { + modes: readonly TeevolutionDpiLightMode[]; + brightness: { min: number; max: number }; + speed: { min: number; max: number }; + }; +} + +export const TEEVOLUTION_DEVICE_PROFILES: ReadonlyMap = new Map([ + [14, { + cid: 14, + name: "Terra Pro", + protocol: "compX-terra-v1", + dpiStageCount: 4, + dpi: { + min: 50, + standardMax: 30_000, + max: TEEVOLUTION_DPI_MAX, + standardStep: 50, + highStep: 100, + }, + pollingRates: TEEVOLUTION_POLLING_RATES, + liftOffDistances: ["Low", "Medium", "High"], + debounce: { min: 0, max: 20 }, + sleepOptions: TEEVOLUTION_SLEEP_OPTIONS, + performanceTimeOptions: TEEVOLUTION_PERFORMANCE_TIME_OPTIONS, + sensorModes: ["Eco", "High"], + dpiLighting: { + modes: TEEVOLUTION_DPI_LIGHT_MODES, + brightness: { min: 1, max: 10 }, + speed: { min: 1, max: 5 }, + }, + }], +]); + +export function teevolutionProfileForCid(cid: number): TeevolutionDeviceProfile | null { + return TEEVOLUTION_DEVICE_PROFILES.get(cid) ?? null; +} + +export const TEEVOLUTION_COMMAND = { + encryptionData: 0x01, + deviceOnline: 0x03, + batteryLevel: 0x04, + writeFlashData: 0x07, + readFlashData: 0x08, + getCurrentConfig: 0x0e, + readVersionId: 0x12, + getDongleVersion: 0x1d, + getRssi: 0x2b, +} as const; + +/** Flash offsets used by Teevolink / Compx HUB for Terra Pro. */ +export const TEEVOLUTION_FLASH = { + reportRate: 0, + currentDpi: 4, + liftOffDistance: 10, + dpiValues: 12, + dpiLightMode: 76, + dpiLightBrightness: 78, + dpiLightSpeed: 80, + dpiLightState: 82, + debounceTime: 169, + motionSync: 171, + sleepTime: 173, + angleSnapping: 175, + rippleControl: 177, + performanceState: 181, + performanceTime: 183, + sensorMode: 185, +} as const; + +export const TEEVOLUTION_TYPE_MAX_POLL: Record = { + 0: 1000, + 1: 4000, + 2: 1000, + 3: 8000, + 4: 2000, + 5: 8000, +}; + +export interface TeevolutionBattery { + percent: number | null; + charging: boolean; +} + +function assertByte(value: number, label: string): void { + if (!Number.isInteger(value) || value < 0 || value > 0xff) { + throw new Error(`${label} must be one byte.`); + } +} + +export function teevolutionPacketChecksum(packet: Uint8Array | readonly number[]): number { + let sum = 0; + const limit = Math.min(packet.length, TEEVOLUTION_PACKET_LENGTH - 1); + for (let index = 0; index < limit; index += 1) sum += packet[index]! & 0xff; + return (0x55 - (sum & 0xff) - TEEVOLUTION_REPORT_ID) & 0xff; +} + +export function teevolutionPacketChecksumIsValid(payload: Uint8Array | readonly number[]): boolean { + if (payload.length !== TEEVOLUTION_PACKET_LENGTH) return false; + return teevolutionPacketChecksum(payload) === (payload[15]! & 0xff); +} + +export function teevolutionDataChecksum(data: Uint8Array | readonly number[]): number { + let sum = 0; + for (const value of data) sum += value & 0xff; + return (0x55 - (sum & 0xff)) & 0xff; +} + +function finalize(payload: Uint8Array): Uint8Array { + payload[15] = teevolutionPacketChecksum(payload); + return payload; +} + +export function teevolutionBuildSimplePayload(command: number): Uint8Array { + assertByte(command, "Teevolution command"); + const payload = new Uint8Array(TEEVOLUTION_PACKET_LENGTH); + payload[0] = command; + return finalize(payload); +} + +export function teevolutionBuildReadPayload(address: number, length: number): Uint8Array { + if (!Number.isInteger(address) || address < 0 || address > 0xffff) { + throw new Error("Teevolution address is out of range."); + } + if (!Number.isInteger(length) || length < 1 || length > TEEVOLUTION_MAX_CHUNK) { + throw new Error("Teevolution read length must be 1–10 bytes."); + } + const payload = teevolutionBuildSimplePayload(TEEVOLUTION_COMMAND.readFlashData); + payload[2] = address >> 8; + payload[3] = address & 0xff; + payload[4] = length; + return finalize(payload); +} + +export function teevolutionBuildWritePayload(address: number, data: readonly number[]): Uint8Array { + if (!Number.isInteger(address) || address < 0 || address > 0xffff) { + throw new Error("Teevolution address is out of range."); + } + if (data.length < 1 || data.length > TEEVOLUTION_MAX_CHUNK) { + throw new Error("Teevolution write length must be 1–10 bytes."); + } + const payload = teevolutionBuildSimplePayload(TEEVOLUTION_COMMAND.writeFlashData); + payload[2] = address >> 8; + payload[3] = address & 0xff; + payload[4] = data.length; + payload.set(data.map((byte) => byte & 0xff), 5); + return finalize(payload); +} + +export function teevolutionBuildWriteScalarPayload(address: number, value: number): Uint8Array { + assertByte(value, "Teevolution scalar"); + return teevolutionBuildWritePayload(address, [value, (0x55 - value) & 0xff]); +} + +export function teevolutionBuildOnlinePayload(enabled: boolean): Uint8Array { + const payload = teevolutionBuildSimplePayload(TEEVOLUTION_COMMAND.deviceOnline); + payload[5] = enabled ? 1 : 0; + return finalize(payload); +} + +export function teevolutionParseReadResponse( + response: Uint8Array, + address: number, + length: number, +): Uint8Array | null { + if (!teevolutionPacketChecksumIsValid(response)) return null; + if (response[0] !== TEEVOLUTION_COMMAND.readFlashData || response[1] !== 0) return null; + if (response[2] !== ((address >> 8) & 0xff) || response[3] !== (address & 0xff)) return null; + if (response[4] !== length || response.length < 5 + length) return null; + return response.slice(5, 5 + length); +} + +export function teevolutionParseBattery(response: Uint8Array): TeevolutionBattery | null { + if (!teevolutionPacketChecksumIsValid(response)) return null; + if (response[0] !== TEEVOLUTION_COMMAND.batteryLevel || response[1] !== 0) return null; + const raw = response[5] ?? 0xff; + return { + percent: raw <= 100 ? raw : null, + charging: response[6] === 1, + }; +} + +export function teevolutionEncodePollingRate(rate: number): number { + if (!(TEEVOLUTION_POLLING_RATES as readonly number[]).includes(rate)) { + throw new Error("Unsupported Teevolution polling rate."); + } + const encoded = rate <= 1000 ? 1000 / rate : rate / 2000 * 16; + if (!Number.isInteger(encoded)) throw new Error("Unsupported Teevolution polling rate."); + return encoded; +} + +export function teevolutionDecodePollingRate(encoded: number): number { + return encoded >= 16 ? encoded / 16 * 2000 : 1000 / encoded; +} + +/** + * PAW3950 Compx packing used by Teevolink: 50 DPI steps below 30,100, then + * 100 DPI steps to 42,000 via dpiEx 0x11 (halve before packing). + */ +export function teevolutionEncodeDpi(dpi: number): Uint8Array { + let dpiEx = 0; + let scaled = dpi; + if (dpi >= 30100) { + dpiEx = 0x11; + scaled = dpi / 2; + } + const raw = scaled / 50 - 1; + if (!Number.isInteger(raw) || raw < 0) { + throw new Error(`${dpi} DPI cannot be encoded for the Terra Pro.`); + } + const high = raw >> 8; + const flags = (high << 2) | (high << 6) | dpiEx | (dpiEx << 4); + const low = raw & 0xff; + return new Uint8Array([low, low, flags & 0xff, teevolutionDataChecksum([low, low, flags & 0xff])]); +} + +export function teevolutionDecodeDpi(data: Uint8Array | readonly number[]): number { + const flags = data[2] ?? 0; + const raw = (data[0] ?? 0) + (((flags & 0x0c) >> 2) << 8); + const dpiEx = flags & 0x03; + let dpi = (raw + 1) * 50; + if ((dpiEx & 0x01) !== 0) dpi *= 2; + if ((dpiEx & 0x02) !== 0) dpi *= 2; + return dpi; +} + +export function teevolutionDecodeLiftOff(value: number): "Low" | "Medium" | "High" | null { + return value === 3 ? "Low" : value === 1 ? "Medium" : value === 2 ? "High" : null; +} + +export function teevolutionEncodeLiftOff(liftOffDistance: "Low" | "Medium" | "High"): number { + return ({ Low: 3, Medium: 1, High: 2 } as const)[liftOffDistance]; +} + +export function teevolutionDecodeFirmwareVersion(label: string, response: Uint8Array): string { + return `${label} v${response[5] ?? 0}.${(response[6] ?? 0).toString(16).padStart(2, "0")}`; +} + +export function teevolutionDpiOptions(profile: TeevolutionDeviceProfile): number[] { + const options: number[] = []; + for (let dpi = profile.dpi.min; dpi <= profile.dpi.standardMax; dpi += profile.dpi.standardStep) { + options.push(dpi); + } + for ( + let dpi = profile.dpi.standardMax + profile.dpi.highStep; + dpi <= profile.dpi.max; + dpi += profile.dpi.highStep + ) { + options.push(dpi); + } + return options; +} + +/** Converts Teevolink's 1–10 brightness slider to the byte stored in flash 0x4E. */ +export function teevolutionEncodeDpiLightBrightness(level: number): number { + if (!Number.isInteger(level) || level < 1 || level > 10) { + throw new Error("Teevolution DPI light brightness must be from 1 to 10."); + } + if (level === 1) return 0x10; + if ([2, 3, 4, 6, 7, 8].includes(level)) return 0x1e * (level - 1); + if (level === 5) return 0x80; + if (level === 9) return 0xe6; + return 0xff; +} + +/** Mirrors Teevolink's fallback to level 5 for an unrecognised stored byte. */ +export function teevolutionDecodeDpiLightBrightness(value: number): number { + if (value !== 0 && value % 0x1e === 0) { + const level = value / 0x1e + 1; + if (Number.isInteger(level) && level >= 2 && level <= 8) return level; + } + if (value === 0x10) return 1; + if (value === 0x80) return 5; + if (value === 0xe6) return 9; + if (value === 0xff) return 10; + return 5; +} + +export function teevolutionDecodeDpiLightMode(mode: number, state: number): TeevolutionDpiLightMode { + if (state !== 1) return 0; + return mode === 2 ? 2 : 1; +} + +export function teevolutionEncodeSensorMode(mode: TeevolutionSensorMode): number { + if (mode === "High") return 1; + if (mode === "Eco") return 0; + throw new Error("Ultra sensor mode is enforced by the link and cannot be written."); +} + +export function teevolutionDecodeSensorModeStored(value: number): TeevolutionSensorMode { + return value === 1 ? "High" : "Eco"; +} + +/** + * Mirrors Teevolink Update_MS_SensorModeDisplay for PAW3950 Terra Pro links. + * Ultra (256) is display-only when high poll rates force corded tracking. + */ +export function teevolutionSensorModeUi(options: { + storedMode: number; + pollingRateHz: number; + connection: "Wired" | "Wireless"; + fps20k?: boolean; +}): { mode: TeevolutionSensorMode; editable: boolean; storedValue: 0 | 1 } { + const storedValue = options.storedMode === 1 ? 1 : 0; + if (options.connection === "Wired" || options.fps20k) { + return { mode: "Ultra", editable: false, storedValue }; + } + if (options.pollingRateHz < 2000) { + return { mode: storedValue === 1 ? "High" : "Eco", editable: true, storedValue }; + } + if (options.pollingRateHz === 8000) { + return { mode: "Ultra", editable: false, storedValue }; + } + // 2000/4000 Hz wireless on PAW3950 uses corded/Ultra tracking. + return { mode: "Ultra", editable: false, storedValue }; +} diff --git a/src/preview-fixtures.ts b/src/preview-fixtures.ts index eace4d6a..d3ff90fd 100644 --- a/src/preview-fixtures.ts +++ b/src/preview-fixtures.ts @@ -222,7 +222,7 @@ const RAZER_VIPER_V4: MouseStatus = { const TEEVOLUTION: MouseStatus = { brand: "Teevolution", name: "Terra Pro", - ui: { defaultDisplayName: "Terra Pro", hideUnsupportedPollingRates: true }, + ui: { defaultDisplayName: "Terra Pro", hideUnsupportedPollingRates: true, hideSignalCard: true }, batteryPercent: 52, batteryState: "Discharging", dpi: 1600, @@ -232,6 +232,17 @@ const TEEVOLUTION: MouseStatus = { liftOffDistance: "Medium", connectionType: "Wireless", connectionDetail: "CID 1 · MID 2 · Type 3", + sensorMode: "Eco", + sensorModeStored: 0, + sensorModeEditable: true, + performanceMode: false, + performanceDuration: 6, + dpiLedMode: 1, + dpiLedBrightness: 5, + dpiLedSpeed: 3, + motionSync: false, + debounceMs: 4, + sleepTimeout: 60, firmware: ["1.0.2"], };