diff --git a/package.json b/package.json index 48f5916..0bdac19 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "test": "node --test src/egg-we-protocol.test.ts", + "test": "node --test src/egg-we-protocol.test.ts src/egg-op1-protocol.test.ts", "preview": "vite preview" }, "devDependencies": { diff --git a/src/control.ts b/src/control.ts index c208520..9f94816 100644 --- a/src/control.ts +++ b/src/control.ts @@ -1,10 +1,9 @@ import "./control.css"; import { - EGG_BUTTON_MAPPINGS, + EGG_BUTTON_ACTION_OPTIONS, EGG_BUTTON_NAMES, EggOp1HidClient, type EggButtonIndex, - type EggButtonMapping, type EggSpdtMode, } from "./egg-op1-hid"; import { @@ -23,6 +22,7 @@ import { } from "./egg-we-control"; import { LogitechHidppClient } from "./logitech-hidpp"; import type { MouseStatus } from "./mouse-types"; +import type { EggButtonAction, EggButtonActionKey, EggOp1Status } from "./egg-op1-protocol"; import { PulsarHidClient } from "./pulsar-hid"; import { PulsarProHidClient } from "./pulsar-pro-hid"; import { SUPPORTED_HID_FILTERS } from "./vendors"; @@ -304,7 +304,7 @@ function renderControl(): void {

DPI

Sensitivity

Choose a DPI value

POLLING RATE

Report frequency

Higher rates update cursor movement more often, but use more battery.
-

SENSOR

Lift-off distance

Controls how far you can lift the mouse before tracking stops. Higher values keep tracking a little longer.
+

SENSOR

Lift-off distance

Controls how far you can lift the mouse before tracking stops. Higher values keep tracking a little longer.
@@ -429,6 +431,21 @@ function renderControl(): void { void applyEggFilter(setting, (event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true"); }); } + const eggSensorToggles = [ + ["#egg-glass-toggle", "glass"], + ["#egg-max-fps-toggle", "maxFps"], + ["#egg-led-lift-toggle", "ledLift"], + ] as const; + for (const [selector, setting] of eggSensorToggles) { + document.querySelector(selector)?.addEventListener("click", (event) => { + void applyEggSensorToggle(setting, (event.currentTarget as HTMLButtonElement).getAttribute("aria-checked") !== "true"); + }); + } + document.querySelector("#egg-angle-tuning")?.addEventListener("change", (event) => { + void applyEggAngleTuning(Number((event.target as HTMLInputElement).value)); + }); + document.querySelector("#egg-reload")?.addEventListener("click", () => { void refreshStatus(); }); + document.querySelector("#egg-factory-reset")?.addEventListener("click", () => { void applyEggFactoryReset(); }); document.querySelector("#left-spdt-select")?.addEventListener("change", (event) => { void applyEggSpdtMode("left", (event.target as HTMLSelectElement).value as EggSpdtMode); }); @@ -438,6 +455,12 @@ function renderControl(): void { document.querySelector("#egg-cpi-levels")?.addEventListener("change", (event) => { void applyEggCpiLevels(Number((event.target as HTMLSelectElement).value)); }); + document.querySelector("#egg-lod-select")?.addEventListener("change", (event) => { + void applyEggLodIndex(Number((event.target as HTMLSelectElement).value)); + }); + document.querySelector("#egg-left-handed")?.addEventListener("change", (event) => { + void applyEggLeftHanded((event.target as HTMLInputElement).checked); + }); document.querySelector("#egg-polling-divider")?.addEventListener("input", updateCustomPollingPreview); document.querySelector("#apply-egg-polling")?.addEventListener("click", () => { const divider = Number(document.querySelector("#egg-polling-divider")?.value); @@ -618,6 +641,8 @@ function resetDeviceSpecificPanels(): void { "#egg-polling-settings", "#egg-cpi-settings", "#egg-button-settings", + "#egg-sensor-tuning", + "#egg-device-actions", "#pulsar-pro-settings", ]) { const element = document.querySelector(selector); @@ -632,6 +657,7 @@ function showStatus(status: MouseStatus): void { const ui = status.ui; const isEgg8k = activeEggClient !== null || (status.brand === "Endgame Gear" && Array.isArray(status.eggCpiStages)); + const eggStatus = isEgg8k ? status as EggOp1Status : null; const isEggWe = ui?.family === "egg-we" || activeEggWeClient !== null; const isEgg = isEgg8k || isEggWe; const isWLMouse = ui?.family === "wlmouse" || activeWLMouseClient !== null; @@ -746,21 +772,54 @@ function showStatus(status: MouseStatus): void { const eggPollingSettings = document.querySelector("#egg-polling-settings"); const eggCpiSettings = document.querySelector("#egg-cpi-settings"); const eggButtonSettings = document.querySelector("#egg-button-settings"); + const eggSensorTuning = document.querySelector("#egg-sensor-tuning"); + const eggDeviceActions = document.querySelector("#egg-device-actions"); if (eggFilterSettings) eggFilterSettings.style.display = isEgg8k ? "block" : "none"; if (eggSpdtSettings) eggSpdtSettings.style.display = isEgg8k ? "block" : "none"; if (eggPollingSettings) eggPollingSettings.style.display = isEgg8k && interfacePreferences.showExperimental ? "block" : "none"; if (eggCpiSettings) eggCpiSettings.style.display = isEgg8k ? "block" : "none"; if (eggButtonSettings) eggButtonSettings.style.display = isEgg8k ? "block" : "none"; - if (isEgg8k) { - setToggleValue("#slamclick-filter-toggle", status.slamclickFilter); - setToggleValue("#motion-jitter-filter-toggle", status.motionJitterFilter); - setControlValue("#left-spdt-select", status.leftSpdtMode); - setControlValue("#right-spdt-select", status.rightSpdtMode); - setControlValue("#egg-cpi-levels", status.eggCpiLevels); - setControlValue("#egg-polling-divider", status.eggPollingDivider); + if (eggSensorTuning) { + eggSensorTuning.style.display = eggStatus + && (eggStatus.eggSupportsGlassMode === true || eggStatus.eggSupportsV2SensorControls === true) + ? "block" + : "none"; + } + if (eggDeviceActions) eggDeviceActions.style.display = isEgg8k ? "block" : "none"; + if (eggStatus) { + setToggleValue("#slamclick-filter-toggle", eggStatus.slamclickFilter); + setToggleValue("#motion-jitter-filter-toggle", eggStatus.motionJitterFilter); + setControlValue("#left-spdt-select", eggStatus.leftSpdtMode); + setControlValue("#right-spdt-select", eggStatus.rightSpdtMode); + setControlValue("#egg-cpi-levels", eggStatus.eggCpiLevels); + setControlValue("#egg-polling-divider", eggStatus.eggPollingDivider); + setToggleValue("#egg-glass-toggle", eggStatus.eggGlassMode); + setToggleValue("#egg-max-fps-toggle", eggStatus.eggForceMaxFps); + setToggleValue("#egg-led-lift-toggle", eggStatus.eggLedLiftOffDisabled); + setControlValue("#egg-angle-tuning", eggStatus.eggAngleTuning); + const glassRow = document.querySelector("#egg-glass-row"); + const v2SensorControls = document.querySelector("#egg-v2-sensor-controls"); + if (glassRow) glassRow.style.display = eggStatus.eggSupportsGlassMode ? "flex" : "none"; + if (v2SensorControls) v2SensorControls.style.display = eggStatus.eggSupportsV2SensorControls ? "block" : "none"; + const customDivider = document.querySelector("#egg-polling-divider"); + const applyDivider = document.querySelector("#apply-egg-polling"); + if (customDivider) customDivider.disabled = settingsPending || eggStatus.eggGlassMode === true; + if (applyDivider) applyDivider.disabled = settingsPending || eggStatus.eggGlassMode === true; + const motionSync = document.querySelector("#motion-sync-toggle"); + if (motionSync && status.pollingRateHz === 8000 && eggStatus.eggMotionSyncAt8k === false) { + motionSync.disabled = true; + motionSync.title = "The PAW3395 cannot use Motion Sync at 8,000 Hz."; + } else if (motionSync) { + motionSync.title = ""; + } + if (eggStatus.eggGlassMode === true) { + setText("#polling-note", "Glass Mode is active; mouse firmware controls the polling divider."); + } + const leftHanded = document.querySelector("#egg-left-handed"); + if (leftHanded) leftHanded.checked = eggStatus.eggLeftHanded; updateCustomPollingPreview(); - renderEggCpiStages(status); - renderEggButtons(status); + renderEggCpiStages(eggStatus); + renderEggButtons(eggStatus); } const proSettings = document.querySelector("#pulsar-pro-settings"); const isPro = status.connectionDetail?.includes("Pulsar Pro protocol") === true; @@ -792,15 +851,25 @@ function showStatus(status: MouseStatus): void { document.querySelector(".control-shell")?.classList.remove("is-empty"); document.querySelectorAll("[data-rate]").forEach((button) => button.classList.toggle("selected", Number(button.dataset.rate) === status.pollingRateHz)); document.querySelectorAll("[data-lod]").forEach((button) => button.classList.toggle("selected", button.dataset.lod === status.liftOffDistance)); + const genericLodOptions = document.querySelector("#generic-lod-options"); + const eggLodSelect = document.querySelector("#egg-lod-select"); + if (genericLodOptions) genericLodOptions.hidden = isEgg8k; + if (eggLodSelect) { + eggLodSelect.hidden = !isEgg8k; + eggLodSelect.disabled = settingsPending; + if (eggStatus) { + eggLodSelect.replaceChildren(...eggStatus.eggLodOptions.map((label, index) => new Option(label, String(index)))); + eggLodSelect.value = String(eggStatus.eggLodIndex); + } + } document.querySelectorAll("[data-rate]").forEach((button) => { const rate = Number(button.dataset.rate); const supportedRates = status.supportedPollingRates; - const unsupportedForEgg8k = isEgg8k && rate < 1000; const unsupportedForListed = Array.isArray(supportedRates) && !supportedRates.includes(rate); const hideListed = (status.brand === "Logitech" || ui?.hideUnsupportedPollingRates) && unsupportedForListed; - const hide = unsupportedForEgg8k || hideListed || settingsPending; + const hide = hideListed || settingsPending; button.hidden = hide; - button.disabled = hide || settingsPending; + button.disabled = hide || settingsPending || eggStatus?.eggGlassMode === true; }); document.querySelectorAll("[data-lod]").forEach((button) => { const hideLow = button.dataset.lod === "Low" @@ -1007,6 +1076,7 @@ async function activateClient(client: SupportedClient): Promise { }).catch(() => false); } else if (client instanceof EggOp1HidClient) { activeEggClient = client; + client.onDeviceChange = () => { void refreshStatus(); }; const status = await client.readStatus(); deviceStatuses.set(client.device, status); dpiOptions = client.getDpiOptions(); @@ -1321,8 +1391,16 @@ async function chooseCustomDpi(): Promise { input.select(); return; } - const dpi = Number(input.value.replace(/[^\d]/g, "")); - if (!Number.isInteger(dpi) || !dpiOptions.includes(dpi)) { + const requestedDpi = Number(input.value.replace(/[^\d]/g, "")); + if (!Number.isInteger(requestedDpi)) { + setText("#read-status", "That DPI value is not supported by this mouse."); + input.focus(); + input.select(); + return; + } + const dpi = activeEggClient?.clampDpi(requestedDpi) ?? requestedDpi; + input.value = String(dpi); + if (!dpiOptions.includes(dpi)) { setText("#read-status", "That DPI value is not supported by this mouse."); input.focus(); input.select(); @@ -1519,23 +1597,29 @@ function updateCustomPollingPreview(): void { : "Enter a divider from 1 to 255."); } -function renderEggCpiStages(status: MouseStatus): void { +function renderEggCpiStages(status: EggOp1Status): void { const container = document.querySelector("#egg-cpi-stage-list"); const stages = status.eggCpiStages; const levels = status.eggCpiLevels ?? 0; if (!container || !stages) return; container.innerHTML = stages.slice(0, levels).map((stage, index) => { const split = stage.x !== stage.y; + const step = stage.x <= 10_000 ? status.eggCpiStepLow : status.eggCpiStepHigh; return `
- Stage ${index + 1} +
- - + +
`; }).join(""); + container.querySelectorAll("[data-active-cpi]").forEach((radio) => { + radio.addEventListener("change", () => { + if (radio.checked) void applyEggActiveCpiStage(Number(radio.dataset.activeCpi)); + }); + }); container.querySelectorAll("[data-cpi-split]").forEach((checkbox) => { checkbox.addEventListener("change", () => { const index = checkbox.dataset.cpiSplit; @@ -1543,52 +1627,164 @@ function renderEggCpiStages(status: MouseStatus): void { if (y) y.disabled = !checkbox.checked; }); }); + container.querySelectorAll("[data-cpi-x], [data-cpi-y]").forEach((input) => { + input.addEventListener("change", () => { clampEggDpiInput(input); }); + }); container.querySelectorAll("[data-apply-cpi]").forEach((button) => { button.addEventListener("click", () => { const level = Number(button.dataset.applyCpi); - const x = Number(container.querySelector(`[data-cpi-x="${level}"]`)?.value); + const xInput = container.querySelector(`[data-cpi-x="${level}"]`); + const x = xInput ? clampEggDpiInput(xInput) : Number.NaN; const split = container.querySelector(`[data-cpi-split="${level}"]`)?.checked === true; - const y = split ? Number(container.querySelector(`[data-cpi-y="${level}"]`)?.value) : x; + const yInput = container.querySelector(`[data-cpi-y="${level}"]`); + const y = split && yInput ? clampEggDpiInput(yInput) : x; void applyEggCpiStage(level, x, y); }); }); } -function renderEggButtons(status: MouseStatus): void { +function renderEggButtons(status: EggOp1Status): void { const container = document.querySelector("#egg-button-list"); const filters = status.eggMulticlickFilters; const mappings = status.eggButtonMappings; - if (!container || !filters || !mappings) return; + const actions = status.eggButtonActions; + if (!container || !filters || !mappings || !actions) return; + const groupedOptions = [...new Set(EGG_BUTTON_ACTION_OPTIONS.map((option) => option.group))].map((group) => + `${EGG_BUTTON_ACTION_OPTIONS.filter((option) => option.group === group).map((option) => + ``).join("")}`).join(""); container.innerHTML = EGG_BUTTON_NAMES.map((name, index) => { const gxActive = index === 0 ? status.leftSpdtMode !== "Off" : index === 1 ? status.rightSpdtMode !== "Off" : false; - const mappingOptions = EGG_BUTTON_MAPPINGS.map((mapping) => - ``).join(""); - const unsupported = EGG_BUTTON_MAPPINGS.includes(mappings[index] as EggButtonMapping) - ? "" : ``; + const fixedPrimary = (index === 0 && !status.eggLeftHanded) || (index === 1 && status.eggLeftHanded); + const action = actions[index]; + const unsupported = action.key === "raw" ? `` : ""; + const multiclick = filters[index] === null || filters[index] === undefined ? "" : ``; + const keyboard = action.key === "keyboard" + ? `` + : ""; + const fixedCpi = action.key === "fixed-cpi" + ? `
` + : ""; return `
${name} - + ${multiclick} + ${fixedPrimary ? `Fixed primary click` : ""} + ${keyboard}${fixedCpi}
`; }).join(""); + actions.forEach((action, index) => { + const select = container.querySelector(`[data-button-mapping="${index}"]`); + if (select && action.key !== "raw") select.value = action.key; + }); container.querySelectorAll("[data-multiclick]").forEach((input) => { input.addEventListener("change", () => void applyEggMulticlick(Number(input.dataset.multiclick) as EggButtonIndex, Number(input.value))); }); container.querySelectorAll("[data-button-mapping]").forEach((select) => { - select.addEventListener("change", () => void applyEggButtonMapping(Number(select.dataset.buttonMapping) as EggButtonIndex, select.value as EggButtonMapping)); + select.addEventListener("change", () => { + const button = Number(select.dataset.buttonMapping) as EggButtonIndex; + const key = select.value as EggButtonActionKey; + if (key === "keyboard") void captureEggShortcut(button); + else if (key === "fixed-cpi") void applyEggButtonMapping(button, { key, x: status.dpi, y: status.dpi }); + else void applyEggButtonMapping(button, { key }); + }); + }); + container.querySelectorAll("[data-capture-key]").forEach((button) => { + button.addEventListener("click", () => void captureEggShortcut(Number(button.dataset.captureKey) as EggButtonIndex)); + }); + container.querySelectorAll("[data-fixed-x], [data-fixed-y]").forEach((input) => { + input.addEventListener("change", () => { clampEggDpiInput(input); }); + }); + container.querySelectorAll("[data-apply-fixed]").forEach((button) => { + button.addEventListener("click", () => { + const index = Number(button.dataset.applyFixed) as EggButtonIndex; + const xInput = container.querySelector(`[data-fixed-x="${index}"]`); + const yInput = container.querySelector(`[data-fixed-y="${index}"]`); + const x = xInput ? clampEggDpiInput(xInput) : Number.NaN; + const y = yInput ? clampEggDpiInput(yInput) : Number.NaN; + void applyEggButtonMapping(index, { key: "fixed-cpi", x, y }); + }); }); } +function eggKeyboardUsage(code: string): number | null { + if (/^Key[A-Z]$/.test(code)) return 0x04 + code.charCodeAt(3) - 65; + if (/^Digit[1-9]$/.test(code)) return 0x1e + Number(code.slice(5)) - 1; + if (code === "Digit0") return 0x27; + if (/^F([1-9]|1[0-2])$/.test(code)) return 0x3a + Number(code.slice(1)) - 1; + if (/^Numpad[1-9]$/.test(code)) return 0x59 + Number(code.slice(6)) - 1; + return ({ + Enter: 0x28, Escape: 0x29, Backspace: 0x2a, Tab: 0x2b, Space: 0x2c, + Minus: 0x2d, Equal: 0x2e, BracketLeft: 0x2f, BracketRight: 0x30, + Backslash: 0x31, Semicolon: 0x33, Quote: 0x34, Backquote: 0x35, + Comma: 0x36, Period: 0x37, Slash: 0x38, CapsLock: 0x39, + PrintScreen: 0x46, ScrollLock: 0x47, Pause: 0x48, Insert: 0x49, + Home: 0x4a, PageUp: 0x4b, Delete: 0x4c, End: 0x4d, PageDown: 0x4e, + ArrowRight: 0x4f, ArrowLeft: 0x50, ArrowDown: 0x51, ArrowUp: 0x52, + NumLock: 0x53, NumpadDivide: 0x54, NumpadMultiply: 0x55, NumpadSubtract: 0x56, + NumpadAdd: 0x57, NumpadEnter: 0x58, Numpad0: 0x62, NumpadDecimal: 0x63, + } as Record)[code] ?? null; +} + +async function captureEggShortcut(button: EggButtonIndex): Promise { + if (!activeEggClient || settingInProgress) return; + setText("#read-status", `Press the keyboard shortcut for ${EGG_BUTTON_NAMES[button]}...`); + const action = await new Promise((resolve) => { + const finish = (value: EggButtonAction | null): void => { + window.clearTimeout(timer); + window.removeEventListener("keydown", onKey, true); + resolve(value); + }; + const onKey = (event: KeyboardEvent): void => { + if (/^(Control|Shift|Alt|Meta)/.test(event.code)) return; + const usage = eggKeyboardUsage(event.code); + if (usage === null) { + setText("#read-status", `${event.code} is not a supported mouse shortcut key. Try another key.`); + return; + } + event.preventDefault(); + event.stopPropagation(); + const modifiers = (event.ctrlKey ? 1 : 0) | (event.shiftKey ? 2 : 0) + | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); + finish({ key: "keyboard", modifiers, usage }); + }; + const timer = window.setTimeout(() => finish(null), 15_000); + window.addEventListener("keydown", onKey, true); + }); + if (action) await applyEggButtonMapping(button, action); + else setText("#read-status", "Keyboard shortcut capture timed out."); +} + +function clampEggDpiInput(input: HTMLInputElement): number { + const requested = Number(input.value); + const clamped = activeEggClient?.clampDpi(requested) ?? requested; + input.value = String(clamped); + return clamped; +} + async function applyEggCpiLevels(levels: number): Promise { await applyEggChange("CPI stage count", async (client) => client.setCpiLevels(levels)); } +async function applyEggActiveCpiStage(level: number): Promise { + await applyEggChange(`active CPI stage ${level + 1}`, async (client) => client.setActiveCpiStage(level)); +} + +async function applyEggLodIndex(index: number): Promise { + await applyEggChange("lift-off distance", async (client) => client.setEggLodIndex(index)); +} + +async function applyEggLeftHanded(enabled: boolean): Promise { + await applyEggChange("left-handed mode", async (client) => client.setLeftHanded(enabled)); +} + async function applyEggCpiStage(level: number, x: number, y: number): Promise { - await applyEggChange(`CPI stage ${level + 1}`, async (client) => client.setCpiStage(level, x, y)); + await applyEggChange(`CPI stage ${level + 1}`, async (client) => { + await client.setCpiStage(level, client.clampDpi(x), client.clampDpi(y)); + }); } async function applyEggPollingDivider(divider: number): Promise { @@ -1599,8 +1795,38 @@ async function applyEggMulticlick(button: EggButtonIndex, value: number): Promis await applyEggChange(`${EGG_BUTTON_NAMES[button]} multiclick filter`, async (client) => client.setMulticlickFilter(button, value)); } -async function applyEggButtonMapping(button: EggButtonIndex, mapping: EggButtonMapping): Promise { - await applyEggChange(`${EGG_BUTTON_NAMES[button]} mapping`, async (client) => client.setButtonMapping(button, mapping)); +async function applyEggButtonMapping(button: EggButtonIndex, action: EggButtonAction): Promise { + await applyEggChange(`${EGG_BUTTON_NAMES[button]} mapping`, async (client) => { + const normalized = action.key === "fixed-cpi" + ? { ...action, x: client.clampDpi(action.x ?? 0), y: client.clampDpi(action.y ?? action.x ?? 0) } + : action; + await client.setButtonMapping(button, normalized); + }); +} + +type EggSensorToggle = "glass" | "maxFps" | "ledLift"; + +async function applyEggSensorToggle(setting: EggSensorToggle, enabled: boolean): Promise { + const label = setting === "glass" + ? "Glass Mode" + : setting === "maxFps" + ? "Force max Sensor FPS" + : "lift-off LED behavior"; + await applyEggChange(label, async (client) => { + if (setting === "glass") await client.setGlassMode(enabled); + if (setting === "maxFps") await client.setForceMaxSensorFps(enabled); + if (setting === "ledLift") await client.setLedLiftOffDisabled(enabled); + }); +} + +async function applyEggAngleTuning(value: number): Promise { + await applyEggChange("Sensor Angle Tuning", async (client) => client.setSensorAngleTuning(value)); +} + +async function applyEggFactoryReset(): Promise { + if (!activeEggClient || settingInProgress) return; + if (!window.confirm("Reset every onboard setting on this mouse to its factory default?")) return; + await applyEggChange("factory defaults", async (client) => client.factoryReset()); } async function applyEggChange(label: string, change: (client: EggOp1HidClient) => Promise): Promise { diff --git a/src/egg-op1-hid.ts b/src/egg-op1-hid.ts index 891b458..fdfb188 100644 --- a/src/egg-op1-hid.ts +++ b/src/egg-op1-hid.ts @@ -1,39 +1,36 @@ import type { MouseStatus } from "./mouse-types"; - -const EGG_VENDOR_ID = 0x3367; -const SUPPORTED_PRODUCTS = new Map([ - [0x1964, "Endgame Gear OP1 8K"], - [0x1966, "Endgame Gear XM2 8K"], - [0x1976, "Endgame Gear OP1 8K Purple Frost"], - [0x1978, "Endgame Gear OP1 8K v2"], -]); - -const CONFIG_SIZE = 1041; -const COMMAND_SIZE = 64; -const REPORT = { - read: 0xa1, - write: 0xa0, -} as const; -const COMMAND = { - firmware: 0x02, - write: 0x11, - read: 0x12, -} as const; -const OFFSET = { - pollingDivider: 21, - filterFlags: 22, - lod: 25, - angleSnapping: 26, - rippleControl: 27, - motionSync: 28, - cpiLevels: 30, - firstCpiSplit: 51, - firstCpiX: 52, - firstCpiY: 54, - firstButton: 77, -} as const; - -const POLLING_RATES = [1000, 2000, 4000, 8000] as const; +import { + EGG_BUTTON_ACTION_OPTIONS, + EGG_COMMAND_SIZE, + EGG_CONFIG_SIZE, + EGG_DEVICE_PROFILES, + EGG_OFFSET, + EGG_OPERATION, + EGG_POLLING_RATES, + EGG_REPORT, + EGG_VENDOR_ID, + eggButtonActionLabel, + eggButtonControlOffset, + eggButtonMappingOffset, + eggClampCpi, + eggDecodeButtonAction, + eggDpiOptions, + eggEncodeButtonAction, + eggFormatFirmwareVersion, + eggIsPlainLeftAction, + eggIsValidCpi, + eggLodOptions, + eggNormalizeFeatureReport, + eggProfileForPid, + eggReadUint16LE, + eggWriteUint16LE, + type EggButtonAction, + type EggDeviceProfile, + type EggOp1Status, +} from "./egg-op1-protocol"; + +const STATUS_OK = 0x01; +const STATUS_BUSY = 0x03; const FILTER = { slamclick: 0x01, motionJitter: 0x10, @@ -44,33 +41,54 @@ const SPDT = { "GX Safe": 0xf0, "GX Speed": 0xf1, } as const; + export type EggSpdtMode = keyof typeof SPDT; -export const EGG_BUTTON_NAMES = ["Left", "Right", "Middle", "Forward", "Back"] as const; -export type EggButtonIndex = 0 | 1 | 2 | 3 | 4; -export const EGG_BUTTON_MAPPINGS = [ - "Left Click", "Right Click", "Middle Click", "Back", "Forward", - "Scroll Up", "Scroll Down", "CPI Cycle", "Disabled", -] as const; -export type EggButtonMapping = (typeof EGG_BUTTON_MAPPINGS)[number]; +export const EGG_BUTTON_NAMES = ["Left", "Right", "Middle", "Forward", "Back", "Wheel Up", "Wheel Down"] as const; +export type EggButtonIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; +export { EGG_BUTTON_ACTION_OPTIONS }; + +interface ReceivedFeature { + bytes: Uint8Array; + rawLength: number; +} export class EggOp1HidClient { - private receivedReportDelta: number | null = null; + private chain: Promise = Promise.resolve(); + private configPayloadLength = EGG_CONFIG_SIZE - 1; + private commandPayloadLength = EGG_COMMAND_SIZE - 1; + private firmwareVersion: string | null | undefined; + + readonly profile: EggDeviceProfile; + onDeviceChange?: () => void; - constructor(readonly device: HIDDevice) {} + private readonly onInputReport = (event: HIDInputReportEvent): void => { + if (event.reportId !== EGG_REPORT.event || event.data.byteLength < 2) return; + const type = event.data.getUint8(0); + if (type === 0x02 || type === 0x06) this.onDeviceChange?.(); + }; + + constructor(readonly device: HIDDevice) { + this.profile = eggProfileForPid(device.productId); + } static isSupported(device: HIDDevice): boolean { return device.vendorId === EGG_VENDOR_ID - && SUPPORTED_PRODUCTS.has(device.productId) - && device.collections.some((collection) => this.collectionHasFeatureReport(collection, REPORT.read)); + && EGG_DEVICE_PROFILES.has(device.productId) + && this.collectionHasFeatureReport(device.collections, EGG_REPORT.command); } - private static collectionHasFeatureReport(collection: HIDCollectionInfo, reportId: number): boolean { - return collection.featureReports.some((report) => report.reportId === reportId) - || collection.children.some((child) => this.collectionHasFeatureReport(child, reportId)); + private static collectionHasFeatureReport(collections: readonly HIDCollectionInfo[], reportId: number): boolean { + return collections.some((collection) => + collection.featureReports.some((report) => report.reportId === reportId) + || this.collectionHasFeatureReport(collection.children, reportId)); } async open(): Promise { if (!this.device.opened) await this.device.open(); + this.configPayloadLength = this.featurePayloadLength(EGG_REPORT.config, EGG_CONFIG_SIZE - 1); + this.commandPayloadLength = this.featurePayloadLength(EGG_REPORT.command, EGG_COMMAND_SIZE - 1); + this.device.removeEventListener("inputreport", this.onInputReport); + this.device.addEventListener("inputreport", this.onInputReport); } describeCollections(): string { @@ -81,108 +99,200 @@ export class EggOp1HidClient { } getDpiOptions(): number[] { - const values: number[] = []; - for (let dpi = 50; dpi <= 26000; dpi += 50) values.push(dpi); - return values; + return eggDpiOptions(this.profile); + } + + clampDpi(dpi: number): number { + return eggClampCpi(this.profile, dpi); } - async readStatus(): Promise { - // Feature-report requests share one endpoint and must not overlap. + async readStatus(): Promise { const config = await this.readConfig(); - const firmware = await this.readFirmware(); - const dpi = this.readUint16LE(config, OFFSET.firstCpiX); - const cpiLevels = Math.min(Math.max(config[OFFSET.cpiLevels], 1), 4); - if (!this.getDpiOptions().includes(dpi)) throw new Error(`The mouse reported an unsupported ${dpi} CPI value.`); + const cpiLevels = Math.min(Math.max(config[EGG_OFFSET.cpiLevels], 1), 4); + const activeCpiStage = Math.min(config[EGG_OFFSET.activeCpiStage], cpiLevels - 1); + const activeOffset = EGG_OFFSET.firstCpiSplit + activeCpiStage * 5; + const dpi = eggReadUint16LE(config, activeOffset + 1); + const dpiY = eggReadUint16LE(config, activeOffset + 3); + if (!eggIsValidCpi(this.profile, dpi) || !eggIsValidCpi(this.profile, dpiY)) { + throw new Error(`The mouse reported an unsupported X ${dpi} / Y ${dpiY} CPI value.`); + } + if (this.firmwareVersion === undefined) { + this.firmwareVersion = await this.readFirmware().catch(() => null); + } + const glassMode = this.profile.lodGlass !== null && config[EGG_OFFSET.glassMode] !== 0; + const lodOptions = eggLodOptions(this.profile, glassMode); + const lodIndex = config[EGG_OFFSET.lod]; + const handedBytes = Array.from(config.slice(EGG_OFFSET.handedButton, EGG_OFFSET.handedButton + 6)); + const leftHanded = !eggIsPlainLeftAction(handedBytes) && handedBytes.some(Boolean); + const buttonActions = EGG_BUTTON_NAMES.map((_, index) => + this.decodePhysicalButtonAction(config, index as EggButtonIndex, leftHanded)); return { brand: "Endgame Gear", - name: SUPPORTED_PRODUCTS.get(this.device.productId) ?? this.device.productName ?? "Endgame Gear OP1 8K", + name: this.profile.name, batteryPercent: null, batteryState: "Unknown", dpi, - pollingRateHz: this.decodePollingRate(config[OFFSET.pollingDivider]), + dpiY, + supportsSeparateDpiAxes: true, + pollingRateHz: this.decodePollingRate(config[EGG_OFFSET.pollingDivider]), + supportedPollingRates: [...EGG_POLLING_RATES], activeProfile: null, connectionType: "Wired", - connectionDetail: `Wired USB · PID 0x${this.device.productId.toString(16).toUpperCase()}`, - motionSync: config[OFFSET.motionSync] !== 0, - angleSnapping: config[OFFSET.angleSnapping] !== 0, - rippleControl: config[OFFSET.rippleControl] !== 0, - slamclickFilter: (config[OFFSET.filterFlags] & FILTER.slamclick) !== 0, - motionJitterFilter: (config[OFFSET.filterFlags] & FILTER.motionJitter) !== 0, - leftSpdtMode: this.decodeSpdtMode(config[OFFSET.firstButton]), - rightSpdtMode: this.decodeSpdtMode(config[OFFSET.firstButton + BUTTON_CONFIG_SIZE]), + connectionDetail: `Wired USB - PID 0x${this.device.productId.toString(16).toUpperCase()} - ${this.profile.sensorFamily.toUpperCase()}`, + motionSync: config[EGG_OFFSET.motionSync] !== 0, + angleSnapping: config[EGG_OFFSET.angleSnapping] !== 0, + rippleControl: config[EGG_OFFSET.rippleControl] !== 0, + slamclickFilter: (config[EGG_OFFSET.filterFlags] & FILTER.slamclick) !== 0, + motionJitterFilter: (config[EGG_OFFSET.filterFlags] & FILTER.motionJitter) !== 0, + leftSpdtMode: this.decodeSpdtMode(config[EGG_OFFSET.firstButton]), + rightSpdtMode: this.decodeSpdtMode(config[EGG_OFFSET.firstButton + BUTTON_CONFIG_SIZE]), eggCpiLevels: cpiLevels, + eggActiveCpiStage: activeCpiStage, + eggCpiMin: this.profile.cpiMin, + eggCpiMax: this.profile.cpiMax, + eggCpiStepLow: this.profile.cpiStepLow, + eggCpiStepHigh: this.profile.cpiStepHigh, eggCpiStages: Array.from({ length: 4 }, (_, level) => { - const offset = OFFSET.firstCpiSplit + level * 5; - return { x: this.readUint16LE(config, offset + 1), y: this.readUint16LE(config, offset + 3) }; + const offset = EGG_OFFSET.firstCpiSplit + level * 5; + return { x: eggReadUint16LE(config, offset + 1), y: eggReadUint16LE(config, offset + 3) }; }), - eggPollingDivider: config[OFFSET.pollingDivider], - eggMulticlickFilters: EGG_BUTTON_NAMES.map((_, index) => { - const value = config[OFFSET.firstButton + index * BUTTON_CONFIG_SIZE]; + eggPollingDivider: config[EGG_OFFSET.pollingDivider], + eggLodIndex: lodIndex, + eggLodOptions: [...lodOptions], + eggGlassMode: glassMode, + eggSupportsGlassMode: this.profile.lodGlass !== null, + eggMotionSyncAt8k: this.profile.motionSyncAt8k, + eggAngleTuning: this.profile.configFamily === "v2" + ? this.decodeInt8(config[EGG_OFFSET.angleTuning]) + : undefined, + eggForceMaxFps: this.profile.configFamily === "v2" + ? config[EGG_OFFSET.forceMaxFps] !== 0 + : undefined, + eggLedLiftOffDisabled: this.profile.configFamily === "v2" + ? config[EGG_OFFSET.ledLiftOff] === 0 + : undefined, + eggSupportsV2SensorControls: this.profile.configFamily === "v2", + eggMulticlickFilters: Array.from({ length: 5 }, (_, index) => { + const offset = eggButtonControlOffset(index); + if (offset === null) throw new Error(`Missing multiclick offset for button ${index}.`); + const value = config[offset]; return value >= 0xf0 ? 8 : value; }), - eggButtonMappings: EGG_BUTTON_NAMES.map((_, index) => - this.decodeButtonMapping(config, index as EggButtonIndex)), - liftOffDistance: config[OFFSET.lod] === 1 ? "Medium" : "High", - firmware: firmware ? [`Firmware ${firmware}`] : ["Firmware unavailable"], + eggButtonMappings: buttonActions.map(eggButtonActionLabel), + eggButtonActions: buttonActions, + eggLeftHanded: leftHanded, + liftOffDistance: this.genericLod(lodOptions[lodIndex]), + firmware: this.firmwareVersion ? [`Firmware ${this.firmwareVersion}`] : ["Firmware unavailable"], }; } async setDpi(dpi: number): Promise { - if (!this.getDpiOptions().includes(dpi)) throw new Error("OP1 8K CPI must be between 50 and 26,000 in 50 CPI steps."); - await this.updateConfig((config) => { - const activeLevels = Math.min(Math.max(config[OFFSET.cpiLevels], 1), 4); - for (let level = 0; level < activeLevels; level += 1) { - const levelOffset = OFFSET.firstCpiSplit + level * 5; - config[levelOffset] = 0; - this.writeUint16LE(config, levelOffset + 1, dpi); - this.writeUint16LE(config, levelOffset + 3, dpi); - } + this.assertCpi(dpi); + const confirmed = await this.updateConfig((config) => { + const levels = Math.min(Math.max(config[EGG_OFFSET.cpiLevels], 1), 4); + const active = Math.min(config[EGG_OFFSET.activeCpiStage], levels - 1); + const offset = EGG_OFFSET.firstCpiSplit + active * 5; + config[offset] = 0; + eggWriteUint16LE(config, offset + 1, dpi); + eggWriteUint16LE(config, offset + 3, dpi); }); - const confirmedConfig = await this.readConfig(); - const activeLevels = Math.min(Math.max(confirmedConfig[OFFSET.cpiLevels], 1), 4); - const confirmedValues = Array.from({ length: activeLevels }, (_, level) => - this.readUint16LE(confirmedConfig, OFFSET.firstCpiX + level * 5)); - if (confirmedValues.some((value) => value !== dpi)) { - throw new Error(`The mouse kept CPI stages at ${confirmedValues.join(", ")} instead of ${dpi} CPI.`); - } - return dpi; + const active = Math.min(confirmed[EGG_OFFSET.activeCpiStage], confirmed[EGG_OFFSET.cpiLevels] - 1); + const confirmedDpi = eggReadUint16LE(confirmed, EGG_OFFSET.firstCpiSplit + active * 5 + 1); + if (confirmedDpi !== dpi) throw new Error(`The mouse kept ${confirmedDpi} CPI instead of ${dpi} CPI.`); + return confirmedDpi; } async setPollingRate(rate: number): Promise { - if (!POLLING_RATES.includes(rate as (typeof POLLING_RATES)[number])) throw new Error("Unsupported OP1 8K polling rate."); + if (!EGG_POLLING_RATES.includes(rate as (typeof EGG_POLLING_RATES)[number])) { + throw new Error("Unsupported Endgame Gear 8K polling rate."); + } const divider = 8000 / rate; - await this.updateConfig((config) => { - config[OFFSET.pollingDivider] = divider; - if (this.device.productId === 0x1966 && rate === 8000) config[OFFSET.motionSync] = 0; + const confirmed = await this.updateConfig((config) => { + if (this.profile.lodGlass !== null && config[EGG_OFFSET.glassMode] !== 0) { + throw new Error("Polling rate is controlled by firmware while Glass Mode is active."); + } + config[EGG_OFFSET.pollingDivider] = divider; + if (rate === 8000 && !this.profile.motionSyncAt8k) config[EGG_OFFSET.motionSync] = 0; }); - const confirmed = this.decodePollingRate((await this.readConfig())[OFFSET.pollingDivider]); - if (confirmed !== rate) throw new Error(`The mouse kept ${confirmed} Hz instead of ${rate} Hz.`); - return confirmed; + const confirmedRate = this.decodePollingRate(confirmed[EGG_OFFSET.pollingDivider]); + if (confirmedRate !== rate) throw new Error(`The mouse kept ${confirmedRate} Hz instead of ${rate} Hz.`); + return confirmedRate; } async setLiftOffDistance(value: NonNullable): Promise { - if (value === "Low") throw new Error("The OP1 8K supports 1 mm or 2 mm lift-off distance."); - const encoded = value === "Medium" ? 1 : 2; - await this.updateConfig((config) => { config[OFFSET.lod] = encoded; }); - if ((await this.readConfig())[OFFSET.lod] !== encoded) throw new Error("The mouse did not confirm the requested lift-off distance."); + const target = value === "Low" ? "0.7 mm" : value === "Medium" ? "1.0 mm" : "2.0 mm"; + const fallback = value === "Medium" ? "1 mm" : value === "High" ? "2 mm" : target; + const config = await this.readConfig(); + const glassMode = this.profile.lodGlass !== null && config[EGG_OFFSET.glassMode] !== 0; + const options = eggLodOptions(this.profile, glassMode); + const index = options.findIndex((option) => option === target || option === fallback); + if (index < 0) throw new Error(`${this.profile.name} does not expose ${target} in its current sensor mode.`); + await this.setEggLodIndex(index); + } + + async setEggLodIndex(index: number): Promise { + const confirmed = await this.updateConfig((config) => { + const glassMode = this.profile.lodGlass !== null && config[EGG_OFFSET.glassMode] !== 0; + const options = eggLodOptions(this.profile, glassMode); + if (!Number.isInteger(index) || index < 0 || index >= options.length) { + throw new Error("Invalid lift-off distance for this mouse and sensor mode."); + } + config[EGG_OFFSET.lod] = index; + }); + if (confirmed[EGG_OFFSET.lod] !== index) throw new Error("The mouse did not confirm the requested lift-off distance."); + } + + async setGlassMode(enabled: boolean): Promise { + if (this.profile.lodGlass === null) throw new Error(`${this.profile.name} does not support Glass Mode.`); + const confirmed = await this.updateConfig((config) => { + config[EGG_OFFSET.glassMode] = enabled ? 1 : 0; + config[EGG_OFFSET.lod] = 0; + }); + if ((confirmed[EGG_OFFSET.glassMode] !== 0) !== enabled) { + throw new Error("The mouse did not confirm Glass Mode."); + } + } + + async setSensorAngleTuning(value: number): Promise { + this.assertV2SensorControl("Sensor Angle Tuning"); + if (!Number.isInteger(value) || value < -127 || value > 127) { + throw new Error("Sensor Angle Tuning must be an integer from -127 to 127."); + } + const confirmed = await this.updateConfig((config) => { config[EGG_OFFSET.angleTuning] = value & 0xff; }); + if (this.decodeInt8(confirmed[EGG_OFFSET.angleTuning]) !== value) { + throw new Error("The mouse did not confirm Sensor Angle Tuning."); + } + } + + async setForceMaxSensorFps(enabled: boolean): Promise { + this.assertV2SensorControl("Force max Sensor FPS"); + await this.setBoolean(EGG_OFFSET.forceMaxFps, enabled, "Force max Sensor FPS"); + } + + async setLedLiftOffDisabled(enabled: boolean): Promise { + this.assertV2SensorControl("Disable LED on Lift-Off"); + const confirmed = await this.updateConfig((config) => { config[EGG_OFFSET.ledLiftOff] = enabled ? 0 : 1; }); + if ((confirmed[EGG_OFFSET.ledLiftOff] === 0) !== enabled) { + throw new Error("The mouse did not confirm the lift-off LED setting."); + } } async setMotionSync(enabled: boolean): Promise { - if (enabled && this.device.productId === 0x1966) { + if (enabled && !this.profile.motionSyncAt8k) { const config = await this.readConfig(); - if (this.decodePollingRate(config[OFFSET.pollingDivider]) === 8000) { - throw new Error("The XM2 8K firmware does not support Motion Sync at 8,000 Hz."); + if (this.decodePollingRate(config[EGG_OFFSET.pollingDivider]) === 8000) { + throw new Error(`${this.profile.name} cannot use Motion Sync at 8,000 Hz.`); } } - await this.setBoolean(OFFSET.motionSync, enabled, "Motion Sync"); + await this.setBoolean(EGG_OFFSET.motionSync, enabled, "Motion Sync"); } async setAngleSnapping(enabled: boolean): Promise { - await this.setBoolean(OFFSET.angleSnapping, enabled, "angle snapping"); + await this.setBoolean(EGG_OFFSET.angleSnapping, enabled, "angle snapping"); } async setRippleControl(enabled: boolean): Promise { - await this.setBoolean(OFFSET.rippleControl, enabled, "ripple control"); + await this.setBoolean(EGG_OFFSET.rippleControl, enabled, "ripple control"); } async setSlamclickFilter(enabled: boolean): Promise { @@ -194,80 +304,147 @@ export class EggOp1HidClient { } async setSpdtMode(button: "left" | "right", mode: EggSpdtMode): Promise { - const offset = OFFSET.firstButton + (button === "right" ? BUTTON_CONFIG_SIZE : 0); - await this.updateConfig((config) => { config[offset] = SPDT[mode]; }); - const confirmed = this.decodeSpdtMode((await this.readConfig())[offset]); - if (confirmed !== mode) throw new Error(`The mouse kept the ${button} button in ${confirmed} mode instead of ${mode}.`); + const offset = EGG_OFFSET.firstButton + (button === "right" ? BUTTON_CONFIG_SIZE : 0); + const confirmed = await this.updateConfig((config) => { config[offset] = SPDT[mode]; }); + const actual = this.decodeSpdtMode(confirmed[offset]); + if (actual !== mode) throw new Error(`The mouse kept the ${button} button in ${actual} mode instead of ${mode}.`); } async setCpiLevels(levels: number): Promise { if (!Number.isInteger(levels) || levels < 1 || levels > 4) throw new Error("The OP1/XM2 supports one to four CPI stages."); - await this.updateConfig((config) => { config[OFFSET.cpiLevels] = levels; }); - if ((await this.readConfig())[OFFSET.cpiLevels] !== levels) throw new Error("The mouse did not confirm the CPI stage count."); + const confirmed = await this.updateConfig((config) => { + config[EGG_OFFSET.cpiLevels] = levels; + if (config[EGG_OFFSET.activeCpiStage] >= levels) config[EGG_OFFSET.activeCpiStage] = levels - 1; + }); + if (confirmed[EGG_OFFSET.cpiLevels] !== levels) throw new Error("The mouse did not confirm the CPI stage count."); + } + + async setActiveCpiStage(level: number): Promise { + const confirmed = await this.updateConfig((config) => { + const levels = Math.min(Math.max(config[EGG_OFFSET.cpiLevels], 1), 4); + if (!Number.isInteger(level) || level < 0 || level >= levels) throw new Error("Invalid active CPI stage."); + config[EGG_OFFSET.activeCpiStage] = level; + }); + if (confirmed[EGG_OFFSET.activeCpiStage] !== level) throw new Error("The mouse did not confirm the active CPI stage."); } async setCpiStage(level: number, x: number, y: number): Promise { if (!Number.isInteger(level) || level < 0 || level > 3) throw new Error("Invalid CPI stage."); - if (![x, y].every((value) => this.getDpiOptions().includes(value))) { - throw new Error("CPI must be between 50 and 26,000 in 50 CPI steps."); - } - const offset = OFFSET.firstCpiSplit + level * 5; - await this.updateConfig((config) => { + this.assertCpi(x); + this.assertCpi(y); + const offset = EGG_OFFSET.firstCpiSplit + level * 5; + const confirmed = await this.updateConfig((config) => { config[offset] = x === y ? 0 : 1; - this.writeUint16LE(config, offset + 1, x); - this.writeUint16LE(config, offset + 3, y); + eggWriteUint16LE(config, offset + 1, x); + eggWriteUint16LE(config, offset + 3, y); }); - const confirmed = await this.readConfig(); - if (this.readUint16LE(confirmed, offset + 1) !== x || this.readUint16LE(confirmed, offset + 3) !== y) { + if (eggReadUint16LE(confirmed, offset + 1) !== x || eggReadUint16LE(confirmed, offset + 3) !== y) { throw new Error(`The mouse did not confirm CPI stage ${level + 1}.`); } } async setCustomPollingDivider(divider: number): Promise { if (!Number.isInteger(divider) || divider < 1 || divider > 255) throw new Error("Polling divider must be an integer from 1 to 255."); - await this.updateConfig((config) => { config[OFFSET.pollingDivider] = divider; }); - if ((await this.readConfig())[OFFSET.pollingDivider] !== divider) throw new Error("The mouse did not confirm the custom polling divider."); + const confirmed = await this.updateConfig((config) => { + if (this.profile.lodGlass !== null && config[EGG_OFFSET.glassMode] !== 0) { + throw new Error("Polling rate is controlled by firmware while Glass Mode is active."); + } + config[EGG_OFFSET.pollingDivider] = divider; + }); + if (confirmed[EGG_OFFSET.pollingDivider] !== divider) throw new Error("The mouse did not confirm the custom polling divider."); } async setMulticlickFilter(button: EggButtonIndex, value: number): Promise { if (!Number.isInteger(value) || value < 0 || value > 25) throw new Error("Multiclick filtering must be from 0 to 25."); - const offset = OFFSET.firstButton + button * BUTTON_CONFIG_SIZE; - const config = await this.readConfig(); - if (button < 2 && config[offset] >= 0xf0) throw new Error(`Turn GX mode off for the ${EGG_BUTTON_NAMES[button]} button first.`); - config[offset] = value; - await this.writeConfig(config); - if ((await this.readConfig())[offset] !== value) throw new Error(`The mouse did not confirm the ${EGG_BUTTON_NAMES[button]} multiclick value.`); - } - - async setButtonMapping(button: EggButtonIndex, mapping: EggButtonMapping): Promise { - const offset = OFFSET.firstButton + button * BUTTON_CONFIG_SIZE + 1; - const encoded = this.encodeButtonMapping(mapping); - await this.updateConfig((config) => { - config.fill(0, offset, offset + 6); - config[offset] = encoded.type; - config[offset + 1] = encoded.value; + const offset = eggButtonControlOffset(button); + if (offset === null) throw new Error(`${EGG_BUTTON_NAMES[button]} has no multiclick filter.`); + const confirmed = await this.updateConfig((config) => { + if (button < 2 && config[offset] >= 0xf0) { + throw new Error(`Turn GX mode off for the ${EGG_BUTTON_NAMES[button]} button first.`); + } + config[offset] = value; + }); + if (confirmed[offset] !== value) throw new Error(`The mouse did not confirm the ${EGG_BUTTON_NAMES[button]} multiclick value.`); + } + + async setButtonMapping(button: EggButtonIndex, action: EggButtonAction): Promise { + const encoded = eggEncodeButtonAction(action); + if (!encoded) throw new Error("Unknown mappings are preserved until a supported action is selected."); + if (action.key === "fixed-cpi") { + this.assertCpi(action.x ?? 0); + this.assertCpi(action.y ?? action.x ?? 0); + } + const confirmed = await this.updateConfig((config) => { + const leftHanded = this.configIsLeftHanded(config); + if ((button === 0 && !leftHanded) || (button === 1 && leftHanded)) { + throw new Error(`${EGG_BUTTON_NAMES[button]} is the fixed primary button in the current handedness mode.`); + } + this.writePhysicalButtonAction(config, button, encoded, leftHanded); + }); + const actual = this.decodePhysicalButtonAction(confirmed, button, this.configIsLeftHanded(confirmed)); + if (JSON.stringify(eggEncodeButtonAction(actual)) !== JSON.stringify(encoded)) { + throw new Error(`The mouse kept the ${EGG_BUTTON_NAMES[button]} mapping as ${eggButtonActionLabel(actual)}.`); + } + } + + async setLeftHanded(enabled: boolean): Promise { + const confirmed = await this.updateConfig((config) => { + if (enabled) { + this.writeActionAt(config, EGG_OFFSET.handedButton, { type: 0x00, params: [0x02, 0, 0, 0, 0] }); + this.writeActionAt(config, EGG_OFFSET.firstButton + 1, { type: 0x00, params: [0x01, 0, 0, 0, 0] }); + } else { + this.writeActionAt(config, EGG_OFFSET.handedButton, { type: 0x00, params: [0x01, 0, 0, 0, 0] }); + this.writeActionAt(config, EGG_OFFSET.firstButton + 1, { type: 0x00, params: [0x02, 0, 0, 0, 0] }); + } + }); + if (this.configIsLeftHanded(confirmed) !== enabled) throw new Error("The mouse did not confirm left-handed mode."); + } + + async factoryReset(): Promise { + await this.run(async () => { + await this.open(); + await this.sendCommand(EGG_OPERATION.factoryReset); + await this.delay(1100); + if (!await this.pollCommandOk(8)) throw new Error("The EGG mouse did not acknowledge the factory reset."); }); - const confirmed = this.decodeButtonMapping(await this.readConfig(), button); - if (confirmed !== mapping) throw new Error(`The mouse kept the ${EGG_BUTTON_NAMES[button]} mapping as ${confirmed}.`); } async close(): Promise { + this.onDeviceChange = undefined; + this.device.removeEventListener("inputreport", this.onInputReport); if (this.device.opened) await this.device.close(); } + private assertCpi(value: number): void { + if (!eggIsValidCpi(this.profile, value)) { + throw new Error( + `${this.profile.name} CPI must be ${this.profile.cpiMin.toLocaleString()} to ${this.profile.cpiMax.toLocaleString()} using the device's supported steps.`, + ); + } + } + + private assertV2SensorControl(label: string): void { + if (this.profile.configFamily !== "v2") throw new Error(`${label} is available only on v2 mice.`); + } + + private decodeInt8(value: number): number { + return value > 127 ? value - 256 : value; + } + private async setBoolean(offset: number, enabled: boolean, label: string): Promise { - await this.updateConfig((config) => { config[offset] = enabled ? 1 : 0; }); - if (((await this.readConfig())[offset] !== 0) !== enabled) throw new Error(`The mouse did not confirm ${label}.`); + const confirmed = await this.updateConfig((config) => { config[offset] = enabled ? 1 : 0; }); + if ((confirmed[offset] !== 0) !== enabled) throw new Error(`The mouse did not confirm ${label}.`); } private async setFilterFlag(flag: number, enabled: boolean, label: string): Promise { - await this.updateConfig((config) => { - config[OFFSET.filterFlags] = enabled - ? config[OFFSET.filterFlags] | flag - : config[OFFSET.filterFlags] & ~flag; + const confirmed = await this.updateConfig((config) => { + config[EGG_OFFSET.filterFlags] = enabled + ? config[EGG_OFFSET.filterFlags] | flag + : config[EGG_OFFSET.filterFlags] & ~flag; }); - const confirmed = ((await this.readConfig())[OFFSET.filterFlags] & flag) !== 0; - if (confirmed !== enabled) throw new Error(`The mouse did not confirm the ${label}.`); + if (((confirmed[EGG_OFFSET.filterFlags] & flag) !== 0) !== enabled) { + throw new Error(`The mouse did not confirm the ${label}.`); + } } private decodeSpdtMode(value: number): EggSpdtMode { @@ -276,130 +453,211 @@ export class EggOp1HidClient { return "Off"; } - private decodeButtonMapping(config: Uint8Array, button: EggButtonIndex): string { - const offset = OFFSET.firstButton + button * BUTTON_CONFIG_SIZE + 1; - const type = config[offset]; - const value = config[offset + 1]; - if (type === 0) { - return ({ 1: "Left Click", 2: "Right Click", 4: "Middle Click", 8: "Back", 16: "Forward" } as Record)[value] - ?? `Unsupported mouse action 0x${value.toString(16)}`; + private configIsLeftHanded(config: Uint8Array): boolean { + const handed = Array.from(config.slice(EGG_OFFSET.handedButton, EGG_OFFSET.handedButton + 6)); + return !eggIsPlainLeftAction(handed) && handed.some(Boolean); + } + + private decodePhysicalButtonAction( + config: Uint8Array, + button: EggButtonIndex, + leftHanded: boolean, + ): EggButtonAction { + if (button === 0) return this.decodeActionAt(config, EGG_OFFSET.handedButton); + const offset = eggButtonMappingOffset(button, leftHanded); + if (offset === null) return { key: "mouse-left" }; + return this.decodeActionAt(config, offset); + } + + private decodeActionAt(config: Uint8Array, offset: number): EggButtonAction { + return eggDecodeButtonAction(config[offset], Array.from(config.slice(offset + 1, offset + 6))); + } + + private writePhysicalButtonAction( + config: Uint8Array, + button: EggButtonIndex, + action: NonNullable>, + leftHanded: boolean, + ): void { + if (button === 0) { + this.writeActionAt(config, EGG_OFFSET.handedButton, action); + if (leftHanded) { + this.writeActionAt(config, EGG_OFFSET.firstButton + 1, { type: 0x00, params: [0x01, 0, 0, 0, 0] }); + } + return; } - if (type === 1) return value === 0xff ? "Scroll Down" : value === 1 ? "Scroll Up" : `Unsupported scroll action ${value}`; - if (type === 9) return "CPI Cycle"; - if (type === 0xff) return "Disabled"; - return `Unsupported mapping type 0x${type.toString(16)}`; + const offset = eggButtonMappingOffset(button, leftHanded); + if (offset === null) throw new Error(`${EGG_BUTTON_NAMES[button]} has no writable mapping slot.`); + this.writeActionAt(config, offset, action); } - private encodeButtonMapping(mapping: EggButtonMapping): { type: number; value: number } { - const mouse = { - "Left Click": 1, "Right Click": 2, "Middle Click": 4, "Back": 8, "Forward": 16, - } as const; - if (mapping in mouse) return { type: 0, value: mouse[mapping as keyof typeof mouse] }; - if (mapping === "Scroll Up") return { type: 1, value: 1 }; - if (mapping === "Scroll Down") return { type: 1, value: 0xff }; - if (mapping === "CPI Cycle") return { type: 9, value: 0 }; - return { type: 0xff, value: 0 }; + private writeActionAt( + config: Uint8Array, + offset: number, + action: NonNullable>, + ): void { + config[offset] = action.type; + for (let index = 0; index < 5; index += 1) config[offset + index + 1] = action.params[index]; } - private async updateConfig(change: (config: Uint8Array) => void): Promise { - const config = await this.readConfig(); - change(config); - await this.writeConfig(config); + private genericLod(label: string | undefined): MouseStatus["liftOffDistance"] { + if (label === "0.7 mm") return "Low"; + if (label === "1 mm" || label === "1.0 mm") return "Medium"; + if (label === "2 mm" || label === "2.0 mm") return "High"; + return null; + } + + private updateConfig(change: (config: Uint8Array) => void): Promise { + return this.run(async () => { + const config = await this.readConfigRaw(); + change(config); + await this.writeConfigRaw(config); + return this.readConfigRaw(); + }); } - private async writeConfig(config: Uint8Array): Promise { - config[0] = REPORT.write; - config[1] = COMMAND.write; - await this.device.sendFeatureReport(REPORT.write, config.slice(1)); + private readConfig(): Promise { + return this.run(() => this.readConfigRaw()); } - private async readConfig(): Promise { + private async readConfigRaw(): Promise { await this.open(); - const command = new Uint8Array(COMMAND_SIZE); - command[0] = REPORT.read; - command[1] = COMMAND.read; - await this.device.sendFeatureReport(REPORT.read, command.slice(1)); - let lastError: Error | null = null; - for (let attempt = 0; attempt < 10; attempt += 1) { - if (attempt > 0) await this.delay(25); - const response = this.copyDataView(await this.device.receiveFeatureReport(REPORT.read)); + const trace: string[] = []; + for (let round = 0; round < 3; round += 1) { + await this.sendCommand(EGG_OPERATION.load); + await this.delay(80); + let backoff = 60; + for (let attempt = 0; attempt < 6; attempt += 1) { + for (const reportId of [EGG_REPORT.command, EGG_REPORT.config]) { + try { + const received = await this.receiveFeature(reportId, EGG_CONFIG_SIZE, this.configPayloadLength); + if (this.isValidConfig(received.bytes, received.rawLength)) { + received.bytes[0] = EGG_REPORT.config; + return received.bytes; + } + trace.push(`GET 0x${reportId.toString(16)} len=${received.rawLength}`); + } catch (error) { + trace.push(error instanceof Error ? error.message : `GET 0x${reportId.toString(16)} failed`); + } + } + await this.delay(backoff); + backoff = Math.min(backoff * 2, 400); + } + } + throw new Error(`The EGG mouse did not return a valid configuration (${trace.slice(-3).join("; ")}).`); + } + + private async writeConfigRaw(config: Uint8Array): Promise { + const payload = new Uint8Array(this.configPayloadLength); + payload.set(config.subarray(1, 1 + Math.min(config.length - 1, payload.length))); + payload[0] = EGG_OPERATION.store; + let lastError: unknown; + for (let attempt = 0; attempt < 4; attempt += 1) { try { - return this.decodeConfigResponse(response); + await this.device.sendFeatureReport(EGG_REPORT.config, payload); + lastError = undefined; + break; } catch (error) { - lastError = error instanceof Error ? error : new Error("The EGG configuration response was invalid."); + lastError = error; + await this.delay(50); } } - throw lastError ?? new Error("The EGG mouse did not return its configuration."); + if (lastError) throw lastError; + await this.delay(300); + const acknowledged = await this.pollCommandOk(8); + if (!acknowledged) throw new Error("The EGG mouse did not acknowledge the configuration write."); + } + + private readFirmware(): Promise { + return this.run(async () => { + await this.open(); + await this.sendCommand(EGG_OPERATION.firmware); + await this.delay(50); + let bestEffortVersion: string | null = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + const response = await this.receiveFeature(EGG_REPORT.command, EGG_COMMAND_SIZE, this.commandPayloadLength); + const version = eggFormatFirmwareVersion(response.bytes); + if (version !== null) bestEffortVersion = version; + if (response.bytes[1] === STATUS_OK && version !== null) return version; + await this.delay(50 * (attempt + 1)); + } + return bestEffortVersion; + }); } - private async readFirmware(): Promise { - await this.open(); - const command = new Uint8Array(COMMAND_SIZE); - command[0] = REPORT.read; - command[1] = COMMAND.firmware; - await this.device.sendFeatureReport(REPORT.read, command.slice(1)); - const response = this.copyDataView(await this.device.receiveFeatureReport(REPORT.read)); - const data = this.alignFeatureResponse(response, COMMAND_SIZE, this.receivedReportDelta ?? 0); - const major = data[17]; - const minor = data[18]; - return major === undefined || minor === undefined ? "" : `${minor.toString(16)}.${major.toString(16)}`; - } - - private decodeConfigResponse(response: Uint8Array): Uint8Array { - const candidates = Array.from({ length: 65 }, (_, index) => index - 32).map((delta) => ({ - bytes: this.alignFeatureResponse(response, CONFIG_SIZE, delta), - delta, - })); - const ranked = candidates - .map((candidate) => ({ ...candidate, score: this.configScore(candidate.bytes) })) - .sort((left, right) => right.score - left.score); - const selected = ranked[0]; - if (selected.score < 7) { - const details = ranked.slice(0, 3).map(({ bytes, delta, score }) => - `delta ${delta}: score ${score}, divider ${bytes[OFFSET.pollingDivider]}, levels ${bytes[OFFSET.cpiLevels]}, LOD ${bytes[OFFSET.lod]}, CPI ${this.readUint16LE(bytes, OFFSET.firstCpiX)}`, - ).join("; "); - const nonzero = [...response.entries()] - .filter(([, value]) => value !== 0) - .slice(0, 16) - .map(([index, value]) => `${index}=0x${value.toString(16).padStart(2, "0")}`) - .join(", "); - throw new Error(`The EGG configuration response could not be decoded (${response.byteLength} bytes; ${details}; first nonzero bytes: ${nonzero || "none"}).`); - } - this.receivedReportDelta = selected.delta; - return selected.bytes; - } - - private configScore(config: Uint8Array): number { - let score = 0; - const divider = config[OFFSET.pollingDivider]; - const levels = config[OFFSET.cpiLevels]; - const lod = config[OFFSET.lod]; - const dpi = this.readUint16LE(config, OFFSET.firstCpiX); - if (divider >= 1 && divider <= 80) score += 2; - if (levels >= 1 && levels <= 4) score += 2; - if (lod === 1 || lod === 2) score += 2; - if (dpi >= 50 && dpi <= 26000 && dpi % 50 === 0) score += 2; - if (config[OFFSET.angleSnapping] <= 1) score += 1; - if (config[OFFSET.rippleControl] <= 1) score += 1; - if (config[OFFSET.motionSync] <= 1) score += 1; - return score; - } - - private alignFeatureResponse(response: Uint8Array, size: number, delta: number): Uint8Array { - const result = new Uint8Array(size); - for (let configIndex = 0; configIndex < size; configIndex += 1) { - const responseIndex = configIndex + delta; - if (responseIndex >= 0 && responseIndex < response.byteLength) result[configIndex] = response[responseIndex]; - } - return result; + private async sendCommand(operation: number): Promise { + const command = new Uint8Array(this.commandPayloadLength); + command[0] = operation; + await this.device.sendFeatureReport(EGG_REPORT.command, command); } - private copyDataView(view: DataView): Uint8Array { - return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + private async pollCommandOk(attempts: number): Promise { + let backoff = 60; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const response = await this.receiveFeature(EGG_REPORT.command, EGG_COMMAND_SIZE, this.commandPayloadLength); + if (response.bytes[1] === STATUS_OK) return true; + if (response.bytes[1] !== STATUS_BUSY && attempt > 1) return false; + await this.delay(backoff); + backoff = Math.min(backoff * 2, 400); + } + return false; + } + + private async receiveFeature(reportId: number, expectedTotal: number, payloadLength: number): Promise { + const view = await Promise.race([ + this.device.receiveFeatureReport(reportId), + new Promise((_, reject) => window.setTimeout( + () => reject(new Error(`GET 0x${reportId.toString(16)} timed out`)), + 3000, + )), + ]); + const raw = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); + return { + bytes: this.normalizeFeature(raw, reportId, expectedTotal, payloadLength), + rawLength: raw.length, + }; } - private delay(milliseconds: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); + private normalizeFeature(raw: Uint8Array, reportId: number, expectedTotal: number, payloadLength: number): Uint8Array { + if (expectedTotal === EGG_CONFIG_SIZE + && raw.length === payloadLength + && (raw[0] === 0 || raw[0] === EGG_REPORT.config || raw[0] === EGG_REPORT.command) + && (raw[1] === STATUS_OK || raw[1] === STATUS_BUSY)) { + const result = new Uint8Array(expectedTotal); + result.set(raw); + result[0] = reportId; + return result; + } + return eggNormalizeFeatureReport(raw, reportId, expectedTotal, payloadLength); + } + + private isValidConfig(config: Uint8Array, rawLength: number): boolean { + const stages = config[EGG_OFFSET.cpiLevels]; + const active = config[EGG_OFFSET.activeCpiStage]; + const divider = config[EGG_OFFSET.pollingDivider]; + const cpi = eggReadUint16LE(config, EGG_OFFSET.firstCpiSplit + 1); + return rawLength >= 131 + && config[1] === STATUS_OK + && stages >= 1 && stages <= 4 + && active <= 3 + && divider >= 1 && divider <= 255 + && eggIsValidCpi(this.profile, cpi); + } + + private featurePayloadLength(reportId: number, fallback: number): number { + const reports: HIDReportInfo[] = []; + const collect = (collections: readonly HIDCollectionInfo[]): void => { + for (const collection of collections) { + reports.push(...collection.featureReports.filter((report) => report.reportId === reportId)); + collect(collection.children); + } + }; + collect(this.device.collections); + for (const report of reports) { + const bits = report.items.reduce((sum, item) => sum + item.reportSize * item.reportCount, 0); + if (bits > 0) return Math.ceil(bits / 8); + } + return fallback; } private decodePollingRate(divider: number): number { @@ -407,12 +665,13 @@ export class EggOp1HidClient { return 8000 / divider; } - private readUint16LE(bytes: Uint8Array, offset: number): number { - return bytes[offset] | (bytes[offset + 1] << 8); + private run(operation: () => Promise): Promise { + const pending = this.chain.then(operation, operation); + this.chain = pending.catch(() => undefined); + return pending; } - private writeUint16LE(bytes: Uint8Array, offset: number, value: number): void { - bytes[offset] = value & 0xff; - bytes[offset + 1] = value >> 8; + private delay(milliseconds: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); } } diff --git a/src/egg-op1-protocol.test.ts b/src/egg-op1-protocol.test.ts new file mode 100644 index 0000000..42c9b68 --- /dev/null +++ b/src/egg-op1-protocol.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + EGG_DEVICE_PROFILES, + eggButtonControlOffset, + eggButtonMappingOffset, + eggClampCpi, + eggDecodeButtonAction, + eggDpiOptions, + eggEncodeButtonAction, + eggFormatFirmwareVersion, + eggIsValidCpi, + eggLodOptions, + eggNormalizeFeatureReport, +} from "./egg-op1-protocol.ts"; + +const op1 = EGG_DEVICE_PROFILES.get(0x1964)!; +const purple = EGG_DEVICE_PROFILES.get(0x1976)!; +const op1v2 = EGG_DEVICE_PROFILES.get(0x1978)!; + +test("all five Endgame Gear 8K devices have explicit capability profiles", () => { + assert.deepEqual([...EGG_DEVICE_PROFILES.keys()], [0x1964, 0x1966, 0x1976, 0x1978, 0x1980]); + assert.equal(op1.motionSyncAt8k, false); + assert.equal(EGG_DEVICE_PROFILES.get(0x1966)!.motionSyncAt8k, false); + assert.equal(purple.motionSyncAt8k, true); + assert.equal(op1v2.motionSyncAt8k, true); +}); + +test("CPI ranges and quantization follow each sensor generation", () => { + assert.equal(eggClampCpi(op1, 30_000), 26_000); + assert.equal(eggClampCpi(op1, 31_000), 26_000); + assert.equal(eggClampCpi(purple, 30_000), 30_000); + assert.equal(eggClampCpi(op1v2, 31_000), 30_000); + assert.equal(eggClampCpi(op1v2, 1_605), 1_610); + assert.equal(eggClampCpi(op1v2, 10_024), 10_000); + assert.equal(eggClampCpi(op1v2, 10_025), 10_050); + assert.equal(eggIsValidCpi(op1v2, 1_610), true); + assert.equal(eggIsValidCpi(op1v2, 1_605), false); + assert.deepEqual(eggDpiOptions(op1v2).slice(0, 3), [10, 20, 30]); + assert.deepEqual(eggDpiOptions(op1v2).slice(-3), [29_900, 29_950, 30_000]); +}); + +test("LOD options switch by device and glass mode", () => { + assert.deepEqual(eggLodOptions(op1, false), ["0.7 mm", "1 mm", "2 mm"]); + assert.equal(eggLodOptions(op1v2, false).length, 11); + assert.deepEqual(eggLodOptions(op1v2, true), ["1.0 mm", "2.0 mm"]); + assert.deepEqual(eggLodOptions(purple, true), ["1.0 mm", "2.0 mm"]); +}); + +test("firmware version bytes are decoded as BCD", () => { + const acknowledgement = new Uint8Array(20); + acknowledgement[1] = 0x01; + assert.equal(eggFormatFirmwareVersion(acknowledgement), null); + + const v107 = new Uint8Array(20); + v107[17] = 0x07; + v107[18] = 0x01; + assert.equal(eggFormatFirmwareVersion(v107), "V1.07"); + + const v137 = new Uint8Array(20); + v137[17] = 0x37; + v137[18] = 0x01; + assert.equal(eggFormatFirmwareVersion(v137), "V1.37"); +}); + +test("firmware response normalization preserves an included report ID", () => { + const raw = new Uint8Array(65); + raw[0] = 0xa1; + raw[17] = 0x07; + raw[18] = 0x01; + const normalized = eggNormalizeFeatureReport(raw, 0xa1, 64, 63); + assert.equal(normalized.length, 65); + assert.equal(eggFormatFirmwareVersion(normalized), "V1.07"); +}); + +test("button control and mapping offsets follow the shifted physical layout", () => { + assert.deepEqual(Array.from({ length: 7 }, (_, button) => eggButtonControlOffset(button)), [77, 84, 91, 98, 105, null, null]); + assert.deepEqual(Array.from({ length: 7 }, (_, button) => eggButtonMappingOffset(button, false)), [null, 78, 85, 92, 99, 113, 120]); + assert.deepEqual(Array.from({ length: 7 }, (_, button) => eggButtonMappingOffset(button, true)), [71, null, 85, 92, 99, 113, 120]); +}); + +test("all supported button actions round-trip through the wire codec", () => { + const actions = [ + { key: "mouse-left" }, + { key: "scroll-down" }, + { key: "keyboard", modifiers: 0x05, usage: 0x06 }, + { key: "cpi-loop" }, + { key: "fixed-cpi", x: 800, y: 1600 }, + { key: "media-mute" }, + { key: "browser-home" }, + { key: "disabled" }, + ] as const; + for (const action of actions) { + const encoded = eggEncodeButtonAction(action)!; + assert.deepEqual(eggEncodeButtonAction(eggDecodeButtonAction(encoded.type, encoded.params)), encoded); + } + assert.deepEqual(eggEncodeButtonAction({ key: "cpi-loop" })!.params, [0xf1, 0, 0, 0, 0]); +}); diff --git a/src/egg-op1-protocol.ts b/src/egg-op1-protocol.ts new file mode 100644 index 0000000..5b9797b --- /dev/null +++ b/src/egg-op1-protocol.ts @@ -0,0 +1,382 @@ +import type { MouseStatus } from "./mouse-types"; + +export type EggButtonActionKey = + | "mouse-left" | "mouse-right" | "mouse-middle" | "mouse-back" | "mouse-forward" + | "scroll-up" | "scroll-down" | "keyboard" | "cpi-loop" | "fixed-cpi" + | "media-play" | "media-next" | "media-previous" | "media-mute" | "media-volume-up" + | "media-volume-down" | "browser-home" | "file-explorer" | "disabled" | "raw"; + +export interface EggButtonAction { + key: EggButtonActionKey; + modifiers?: number; + usage?: number; + x?: number; + y?: number; + rawType?: number; + rawParams?: number[]; +} + +export interface EggOp1Status extends MouseStatus { + eggCpiLevels: number; + eggCpiStages: Array<{ x: number; y: number }>; + eggActiveCpiStage: number; + eggCpiMin: number; + eggCpiMax: number; + eggCpiStepLow: number; + eggCpiStepHigh: number; + eggPollingDivider: number; + eggLodIndex: number; + eggLodOptions: string[]; + eggMulticlickFilters: number[]; + eggButtonMappings: string[]; + eggGlassMode: boolean; + eggSupportsGlassMode: boolean; + eggMotionSyncAt8k: boolean; + eggAngleTuning?: number; + eggForceMaxFps?: boolean; + eggLedLiftOffDisabled?: boolean; + eggSupportsV2SensorControls: boolean; + eggButtonActions: EggButtonAction[]; + eggLeftHanded: boolean; +} + +export const EGG_VENDOR_ID = 0x3367; + +export type EggSensorFamily = "paw3395" | "paw3950"; +export type EggConfigFamily = "v1" | "v2"; + +export interface EggDeviceProfile { + pid: number; + name: string; + configFamily: EggConfigFamily; + sensorFamily: EggSensorFamily; + cpiMin: number; + cpiMax: number; + cpiStepLow: number; + cpiStepHigh: number; + lodNormal: readonly string[]; + lodGlass: readonly string[] | null; + motionSyncAt8k: boolean; +} + +const LOD_V1 = ["0.7 mm", "1 mm", "2 mm"] as const; +const LOD_V2 = [ + "0.7 mm", "0.8 mm", "0.9 mm", "1.0 mm", "1.1 mm", "1.2 mm", + "1.3 mm", "1.4 mm", "1.5 mm", "1.6 mm", "1.7 mm", +] as const; +const LOD_GLASS = ["1.0 mm", "2.0 mm"] as const; + +export const EGG_DEVICE_PROFILES: ReadonlyMap = new Map([ + [0x1964, { + pid: 0x1964, + name: "Endgame Gear OP1 8K", + configFamily: "v1", + sensorFamily: "paw3395", + cpiMin: 50, + cpiMax: 26_000, + cpiStepLow: 50, + cpiStepHigh: 50, + lodNormal: LOD_V1, + lodGlass: null, + motionSyncAt8k: false, + }], + [0x1966, { + pid: 0x1966, + name: "Endgame Gear XM2 8K", + configFamily: "v1", + sensorFamily: "paw3395", + cpiMin: 50, + cpiMax: 26_000, + cpiStepLow: 50, + cpiStepHigh: 50, + lodNormal: LOD_V1, + lodGlass: null, + motionSyncAt8k: false, + }], + [0x1976, { + pid: 0x1976, + name: "Endgame Gear OP1 8K Purple Frost", + configFamily: "v1", + sensorFamily: "paw3950", + cpiMin: 50, + cpiMax: 30_000, + cpiStepLow: 50, + cpiStepHigh: 50, + lodNormal: LOD_V1, + lodGlass: LOD_GLASS, + motionSyncAt8k: true, + }], + [0x1978, { + pid: 0x1978, + name: "Endgame Gear OP1 8K v2", + configFamily: "v2", + sensorFamily: "paw3950", + cpiMin: 10, + cpiMax: 30_000, + cpiStepLow: 10, + cpiStepHigh: 50, + lodNormal: LOD_V2, + lodGlass: LOD_GLASS, + motionSyncAt8k: true, + }], + [0x1980, { + pid: 0x1980, + name: "Endgame Gear XM2 8K v2", + configFamily: "v2", + sensorFamily: "paw3950", + cpiMin: 10, + cpiMax: 30_000, + cpiStepLow: 10, + cpiStepHigh: 50, + lodNormal: LOD_V2, + lodGlass: LOD_GLASS, + motionSyncAt8k: true, + }], +]); + +export const EGG_REPORT = { + config: 0xa0, + command: 0xa1, + event: 0x03, +} as const; + +export const EGG_OPERATION = { + firmware: 0x02, + store: 0x11, + load: 0x12, + factoryReset: 0x13, +} as const; + +export const EGG_OFFSET = { + pollingDivider: 21, + filterFlags: 22, + ledLiftOff: 24, + lod: 25, + angleSnapping: 26, + rippleControl: 27, + motionSync: 28, + activeCpiStage: 29, + cpiLevels: 30, + firstCpiSplit: 51, + handedButton: 71, + firstButton: 77, + glassMode: 127, + angleTuning: 128, + forceMaxFps: 129, +} as const; + +export const EGG_CONFIG_SIZE = 1041; +export const EGG_COMMAND_SIZE = 64; +export const EGG_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000] as const; + +export function eggNormalizeFeatureReport( + raw: Uint8Array, + reportId: number, + expectedTotal: number, + payloadLength: number, +): Uint8Array { + let includesId: boolean; + if (payloadLength > 0 && raw.length === payloadLength) includesId = false; + else if (payloadLength > 0 && raw.length === payloadLength + 1 && raw[0] === reportId) includesId = true; + else includesId = raw.length > 0 && raw[0] === reportId; + const result = new Uint8Array(Math.max(expectedTotal, raw.length + (includesId ? 0 : 1))); + if (includesId) result.set(raw); + else { + result[0] = reportId; + result.set(raw, 1); + } + return result; +} + +export function eggProfileForPid(pid: number): EggDeviceProfile { + const profile = EGG_DEVICE_PROFILES.get(pid); + if (!profile) throw new Error(`Unsupported Endgame Gear product 0x${pid.toString(16)}.`); + return profile; +} + +export function eggCpiStep(profile: EggDeviceProfile, cpi: number): number { + return profile.configFamily === "v2" && cpi <= 10_000 + ? profile.cpiStepLow + : profile.cpiStepHigh; +} + +export function eggClampCpi(profile: EggDeviceProfile, value: number): number { + const clamped = Math.max(profile.cpiMin, Math.min(profile.cpiMax, Math.round(value))); + const step = eggCpiStep(profile, clamped); + const rounded = Math.floor((clamped + step / 2) / step) * step; + return Math.max(profile.cpiMin, Math.min(profile.cpiMax, rounded)); +} + +export function eggIsValidCpi(profile: EggDeviceProfile, value: number): boolean { + return Number.isInteger(value) && value === eggClampCpi(profile, value); +} + +export function eggDpiOptions(profile: EggDeviceProfile): number[] { + const values: number[] = []; + for (let cpi = profile.cpiMin; cpi <= profile.cpiMax;) { + values.push(cpi); + cpi += eggCpiStep(profile, cpi + 1); + } + return values; +} + +export function eggLodOptions(profile: EggDeviceProfile, glassMode: boolean): readonly string[] { + return glassMode && profile.lodGlass ? profile.lodGlass : profile.lodNormal; +} + +function decodeBcdByte(value: number): number | null { + const high = value >> 4; + const low = value & 0x0f; + return high <= 9 && low <= 9 ? high * 10 + low : null; +} + +/** Firmware stores BCD patch/major bytes, e.g. 37 01 => V1.37. */ +export function eggFormatFirmwareVersion(bytes: Uint8Array): string | null { + for (const offset of [17, 16, 18, 2, 4, 6]) { + const patch = decodeBcdByte(bytes[offset] ?? 0xff); + const major = decodeBcdByte(bytes[offset + 1] ?? 0xff); + if (patch !== null && major !== null && major > 0) { + return `V${major}.${String(patch).padStart(2, "0")}`; + } + } + return null; +} + +export function eggReadUint16LE(bytes: Uint8Array, offset: number): number { + return bytes[offset] | (bytes[offset + 1] << 8); +} + +export function eggWriteUint16LE(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = value >> 8; +} + +export const EGG_BUTTON_ACTION_OPTIONS = [ + { group: "Mouse", key: "mouse-left", label: "Left Click" }, + { group: "Mouse", key: "mouse-right", label: "Right Click" }, + { group: "Mouse", key: "mouse-middle", label: "Middle Click" }, + { group: "Mouse", key: "mouse-back", label: "Back" }, + { group: "Mouse", key: "mouse-forward", label: "Forward" }, + { group: "Scroll", key: "scroll-up", label: "Wheel Up" }, + { group: "Scroll", key: "scroll-down", label: "Wheel Down" }, + { group: "Keyboard", key: "keyboard", label: "Keyboard shortcut" }, + { group: "CPI", key: "cpi-loop", label: "CPI Loop" }, + { group: "CPI", key: "fixed-cpi", label: "Fixed CPI" }, + { group: "Media", key: "media-play", label: "Play / Pause" }, + { group: "Media", key: "media-next", label: "Next Track" }, + { group: "Media", key: "media-previous", label: "Previous Track" }, + { group: "Media", key: "media-mute", label: "Mute" }, + { group: "Media", key: "media-volume-up", label: "Volume Up" }, + { group: "Media", key: "media-volume-down", label: "Volume Down" }, + { group: "System", key: "browser-home", label: "Browser Home" }, + { group: "System", key: "file-explorer", label: "File Explorer" }, + { group: "Other", key: "disabled", label: "Disabled" }, +] as const; + +export interface EggEncodedButtonAction { + type: number; + params: [number, number, number, number, number]; +} + +export function eggDecodeButtonAction(type: number, params: readonly number[]): EggButtonAction { + const p = (index: number): number => params[index] ?? 0; + if (type === 0x00) { + const key = ({ + 0x01: "mouse-left", + 0x02: "mouse-right", + 0x04: "mouse-middle", + 0x08: "mouse-back", + 0x10: "mouse-forward", + } as const)[p(0) as 0x01 | 0x02 | 0x04 | 0x08 | 0x10]; + if (key) return { key }; + } + if (type === 0x01 && p(0) === 0x01) return { key: "scroll-up" }; + if (type === 0x01 && p(0) === 0xff) return { key: "scroll-down" }; + if (type === 0x02) return { key: "keyboard", modifiers: p(0) & 0x0f, usage: p(1) }; + if (type === 0x09) return { key: "cpi-loop" }; + if (type === 0x0c) { + return { key: "fixed-cpi", x: p(1) | (p(2) << 8), y: p(3) | (p(4) << 8) }; + } + if (type === 0x18 && p(0) === 0x96) return { key: "browser-home" }; + if (type === 0x18 && p(0) === 0x94) return { key: "file-explorer" }; + const media = ({ + 0xcd: "media-play", + 0xb5: "media-next", + 0xb6: "media-previous", + 0xe2: "media-mute", + 0xe9: "media-volume-up", + 0xea: "media-volume-down", + } as const)[p(0) as 0xcd | 0xb5 | 0xb6 | 0xe2 | 0xe9 | 0xea]; + if (type === 0x20 && media) return { key: media }; + if (type === 0xff) return { key: "disabled" }; + return { key: "raw", rawType: type, rawParams: Array.from({ length: 5 }, (_, index) => p(index)) }; +} + +export function eggEncodeButtonAction(action: EggButtonAction): EggEncodedButtonAction | null { + const params = (...values: number[]): [number, number, number, number, number] => { + const result: [number, number, number, number, number] = [0, 0, 0, 0, 0]; + values.slice(0, 5).forEach((value, index) => { result[index] = value & 0xff; }); + return result; + }; + switch (action.key) { + case "mouse-left": return { type: 0x00, params: params(0x01) }; + case "mouse-right": return { type: 0x00, params: params(0x02) }; + case "mouse-middle": return { type: 0x00, params: params(0x04) }; + case "mouse-back": return { type: 0x00, params: params(0x08) }; + case "mouse-forward": return { type: 0x00, params: params(0x10) }; + case "scroll-up": return { type: 0x01, params: params(0x01) }; + case "scroll-down": return { type: 0x01, params: params(0xff) }; + case "keyboard": return { type: 0x02, params: params(action.modifiers ?? 0, action.usage ?? 0) }; + case "cpi-loop": return { type: 0x09, params: params(0xf1) }; + case "fixed-cpi": { + const x = action.x ?? 1600; + const y = action.y ?? x; + return { type: 0x0c, params: params(0, x, x >> 8, y, y >> 8) }; + } + case "browser-home": return { type: 0x18, params: params(0x96) }; + case "file-explorer": return { type: 0x18, params: params(0x94) }; + case "media-play": return { type: 0x20, params: params(0xcd) }; + case "media-next": return { type: 0x20, params: params(0xb5) }; + case "media-previous": return { type: 0x20, params: params(0xb6) }; + case "media-mute": return { type: 0x20, params: params(0xe2) }; + case "media-volume-up": return { type: 0x20, params: params(0xe9) }; + case "media-volume-down": return { type: 0x20, params: params(0xea) }; + case "disabled": return { type: 0xff, params: params() }; + case "raw": return null; + } +} + +export function eggButtonActionLabel(action: EggButtonAction): string { + if (action.key === "raw") return "Custom (preserved)"; + if (action.key === "keyboard") { + const modifiers = [ + [0x01, "Ctrl"], [0x02, "Shift"], [0x04, "Alt"], [0x08, "Win"], + ] as const; + const parts: string[] = modifiers.filter(([mask]) => (action.modifiers ?? 0) & mask).map(([, label]) => label); + parts.push(action.usage ? `HID 0x${action.usage.toString(16).padStart(2, "0")}` : "Unassigned"); + return parts.join(" + "); + } + if (action.key === "fixed-cpi") { + return action.x === action.y ? `CPI ${action.x}` : `X ${action.x} / Y ${action.y}`; + } + return EGG_BUTTON_ACTION_OPTIONS.find((option) => option.key === action.key)?.label ?? action.key; +} + +export function eggIsPlainLeftAction(bytes: readonly number[]): boolean { + return bytes[0] === 0x00 && bytes[1] === 0x01 && !bytes.slice(2, 6).some(Boolean); +} + +const BUTTON_CONTROL_INDEX = [0, 1, 2, 3, 4, null, null] as const; +const BUTTON_MAPPING_INDEX = [null, 0, 1, 2, 3, 5, 6] as const; + +export function eggButtonControlOffset(button: number): number | null { + const index = BUTTON_CONTROL_INDEX[button]; + return index === null || index === undefined ? null : EGG_OFFSET.firstButton + index * 7; +} + +export function eggButtonMappingOffset(button: number, leftHanded: boolean): number | null { + if (button === 0) return leftHanded ? EGG_OFFSET.handedButton : null; + if (button === 1 && leftHanded) return null; + const index = BUTTON_MAPPING_INDEX[button]; + return index === null || index === undefined ? null : EGG_OFFSET.firstButton + index * 7 + 1; +}