Skip to content
Merged
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
9 changes: 4 additions & 5 deletions build/check-bundle-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ const BUDGET_BYTES: Record<string, number> = {
// 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");
Expand Down
23 changes: 23 additions & 0 deletions src/control-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export interface ControlEventHandlers {
toggleSleep(enabled: boolean): void;
applyLowPowerThreshold(percent: number): void;
applyPulsarToggle(setting: PulsarToggleSetting, enabled: boolean): void;
applyTeevolutionSensorMode(mode: NonNullable<MouseStatus["sensorMode"]>): 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;
Expand Down Expand Up @@ -176,6 +179,26 @@ export function bindControlEvents(handlers: ControlEventHandlers): void {
void handlers.applyPulsarToggle(setting, (event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true");
});
}
document.querySelector<HTMLSelectElement>("#teevolution-sensor-mode")?.addEventListener("change", (event) => {
const mode = (event.target as HTMLSelectElement).value as NonNullable<MouseStatus["sensorMode"]>;
void handlers.applyTeevolutionSensorMode(mode);
});
document.querySelector<HTMLSelectElement>("#teevolution-performance-duration")?.addEventListener("change", (event) => {
void handlers.applyTeevolutionPerformanceDuration(Number((event.target as HTMLSelectElement).value));
});
document.querySelector<HTMLSelectElement>("#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<HTMLInputElement>(`#teevolution-dpi-light-${setting}`);
input?.addEventListener("input", () => {
const output = document.querySelector<HTMLOutputElement>(`#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<HTMLButtonElement>(selector)?.addEventListener("click", (event) => {
void handlers.applyEggFilter(setting, (event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true");
Expand Down
3 changes: 2 additions & 1 deletion src/control-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ export function controlTemplate(buildLabel: string): string {
<article id="debounce-settings" class="setting-card"><div class="${CARD_HEADING}"><div><p>CLICK</p><h2>Debounce</h2></div></div><select id="debounce-select"></select></article>
<article id="sleep-settings" class="setting-card"><div class="${CARD_HEADING}"><div><p>POWER</p><h2>Auto sleep</h2></div><button id="sleep-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false" hidden>Off</button></div><select id="sleep-select"><option value="1">10 seconds</option><option value="3">30 seconds</option><option value="6">1 minute</option><option value="12">2 minutes</option><option value="30">5 minutes</option><option value="60">10 minutes</option><option value="180">30 minutes</option></select></article>
<article id="low-power-settings" class="setting-card" hidden><div class="${CARD_HEADING}"><div><p>POWER</p><h2>Low power mode</h2></div></div><select id="low-power-select"></select><small id="low-power-note" class="setting-note">Slows the mouse down to save battery below this level.</small></article>
<article id="processing-settings" class="setting-card"><div class="${CARD_HEADING}"><div><p>SENSOR</p><h2>Processing</h2></div></div><div class="${TOGGLE_ROW}"><span>Motion Sync</span><button id="motion-sync-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div><div class="${TOGGLE_ROW}"><span>Angle snapping</span><button id="angle-snapping-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div><div class="${TOGGLE_ROW}"><span>Ripple control</span><button id="ripple-control-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div><div id="performance-mode-setting" class="${TOGGLE_ROW}"><span>Performance mode</span><button id="performance-mode-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div></article>
<article id="processing-settings" class="setting-card"><div class="${CARD_HEADING}"><div><p>SENSOR</p><h2>Processing</h2></div></div><div id="teevolution-sensor-mode-row" class="${FIELD_LABEL} spaced" hidden><span>Sensor mode</span><select id="teevolution-sensor-mode"><option value="Eco">Eco</option><option value="High">High</option><option value="Ultra">Ultra</option></select><small id="teevolution-sensor-mode-note" class="setting-note"></small></div><div class="${TOGGLE_ROW}"><span>Motion Sync</span><button id="motion-sync-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div><div class="${TOGGLE_ROW}"><span>Angle snapping</span><button id="angle-snapping-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div><div class="${TOGGLE_ROW}"><span>Ripple control</span><button id="ripple-control-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div><div id="performance-mode-setting" class="${TOGGLE_ROW}"><span id="performance-mode-label">Performance mode</span><button id="performance-mode-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div><label id="teevolution-performance-duration-row" class="${FIELD_LABEL} spaced" hidden>Duration<select id="teevolution-performance-duration"></select></label></article>
<article id="teevolution-dpi-lighting" class="setting-card" data-pending-key="teevolution-dpi-light-mode teevolution-dpi-light-brightness teevolution-dpi-light-speed" hidden><div class="${CARD_HEADING}"><div><p>LIGHTING</p><h2>DPI indicator</h2></div></div><label class="${FIELD_LABEL}">Effect<select id="teevolution-dpi-light-mode"><option value="0">Off</option><option value="1">Steady</option><option value="2">Breathing</option></select></label><div class="lod-sliders teevolution-dpi-light-controls"><label>Brightness<output id="teevolution-dpi-light-brightness-output">5</output><input id="teevolution-dpi-light-brightness" type="range" min="1" max="10" step="1" /></label><label>Speed<output id="teevolution-dpi-light-speed-output">3</output><input id="teevolution-dpi-light-speed" type="range" min="1" max="5" step="1" /></label></div><small class="setting-note">Uses the active DPI stage colour stored by the mouse.</small></article>
<article id="finalmouse-settings" class="setting-card" style="display:none" data-pending-key="finalmouse-dongle-led finalmouse-tournament-scroll finalmouse-tournament-timeout"><div class="${CARD_HEADING}"><div><p>FINALMOUSE</p><h2>Dongle and tournament mode</h2></div></div><label class="${FIELD_LABEL}">Dongle LED<select id="finalmouse-dongle-led"><option value="0">Off</option><option value="1">Battery indicator</option><option value="2">Solid white</option></select></label><label class="${FIELD_LABEL} spaced">Tournament scroll<select id="finalmouse-tournament-scroll"><option value="0">Off</option><option value="1">Scroll up</option><option value="2">Scroll down</option><option value="3">Both directions</option></select></label><label class="${FIELD_LABEL} spaced">Passthrough window<select id="finalmouse-tournament-timeout"><option value="100">100 ms</option><option value="500">500 ms</option><option value="1000">1 second</option><option value="1500">1.5 seconds</option></select></label></article>
<article id="egg-filter-settings" class="setting-card" style="display:none"><div class="${CARD_HEADING}"><div><p>SENSOR</p><h2>Filters</h2></div></div><div class="${TOGGLE_ROW}"><span>Slamclick filter</span><button id="slamclick-filter-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div><div class="${TOGGLE_ROW}"><span>Motion-jitter filter</span><button id="motion-jitter-filter-toggle" class="${TOGGLE}" type="button" role="switch" aria-checked="false">Off</button></div></article>
<article id="egg-spdt-settings" class="setting-card" style="display:none"><div class="${CARD_HEADING}"><div><p>CLICK</p><h2>GX switch mode</h2></div></div><label class="${FIELD_LABEL}">Left button<select id="left-spdt-select"><option>Off</option><option>GX Safe</option><option>GX Speed</option></select></label><label class="${FIELD_LABEL} spaced">Right button<select id="right-spdt-select"><option>Off</option><option>GX Safe</option><option>GX Speed</option></select></label></article>
Expand Down
1 change: 1 addition & 0 deletions src/control.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
143 changes: 141 additions & 2 deletions src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -392,6 +393,9 @@ function renderControl(): void {
toggleSleep: (enabled) => applyPulsarValue("sleep", enabled ? lastSleepSeconds : WLMOUSE_SLEEP_NEVER),
applyLowPowerThreshold,
applyPulsarToggle,
applyTeevolutionSensorMode,
applyTeevolutionPerformanceDuration,
applyTeevolutionDpiLighting,
applyEggFilter,
applyEggSpdtMode,
applyEggCpiLevels,
Expand Down Expand Up @@ -679,7 +683,11 @@ function sleepLabel(seconds: number): string {
}

function fillSleepOptions(options: ReadonlyArray<readonly [number, string]>): void {
const select = document.querySelector<HTMLSelectElement>("#sleep-select");
fillSelectOptions("#sleep-select", options);
}

function fillSelectOptions(selector: string, options: ReadonlyArray<readonly [number, string]>): void {
const select = document.querySelector<HTMLSelectElement>(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))));
Expand Down Expand Up @@ -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<HTMLElement>("#performance-mode-label");
if (performanceModeLabel) performanceModeLabel.textContent = isTeevolution ? "Highest performance" : "Performance mode";
const teevolutionSensorRow = document.querySelector<HTMLElement>("#teevolution-sensor-mode-row");
const teevolutionDurationRow = document.querySelector<HTMLElement>("#teevolution-performance-duration-row");
const teevolutionDpiLighting = document.querySelector<HTMLElement>("#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<HTMLSelectElement>("#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<HTMLSelectElement>("#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<HTMLSelectElement>("#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<HTMLInputElement>("#teevolution-dpi-light-brightness");
const speed = document.querySelector<HTMLInputElement>("#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<HTMLElement>(selector)?.closest<HTMLElement>(".switch-row");
if (row) {
Expand Down Expand Up @@ -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<MouseStatus["sensorMode"]>): 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<void> {
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";
Expand Down
4 changes: 4 additions & 0 deletions src/devices/mouse-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading