From 587db98fa00a399798d8c0e95985af74b30d682d Mon Sep 17 00:00:00 2001 From: qsxcv Date: Sat, 1 Aug 2026 04:10:38 -0400 Subject: [PATCH 1/3] fix(egg): correct 8K profiles and config transport Model each supported wired EGG 8K mouse explicitly and make config exchange match the A0/A1 feature-report framing used by the devices. - Add profiles for OP1 8K, XM2 8K, OP1 8K Purple Frost, OP1 8K v2, and XM2 8K v2. - Define model-specific CPI ranges, CPI steps, LOD options, and 8K Motion Sync support. - Clamp custom DPI and CPI-stage input to values supported by the active model. - Read and update the active CPI stage instead of assuming the first stage. - Decode both firmware version bytes as BCD, including V1.07 and V1.37. - Preserve firmware response alignment when WebHID includes the report ID. - Ignore short command acknowledgements while waiting for firmware version data. - Normalize A0 and A1 responses without shifting documented config offsets. - Retry config reads and writes, validate response frames, and require write acknowledgements. - Preserve unknown config bytes with read-modify-write updates and verify stored values. - Keep EGG-only status fields in the EGG protocol module. - Cover model capabilities, CPI clamping, LOD options, firmware parsing, and report normalization with tests. --- package.json | 2 +- src/control.ts | 96 ++++-- src/egg-op1-hid.ts | 585 +++++++++++++++++++++-------------- src/egg-op1-protocol.test.ts | 71 +++++ src/egg-op1-protocol.ts | 226 ++++++++++++++ 5 files changed, 730 insertions(+), 250 deletions(-) create mode 100644 src/egg-op1-protocol.test.ts create mode 100644 src/egg-op1-protocol.ts 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..98cf20e 100644 --- a/src/control.ts +++ b/src/control.ts @@ -23,6 +23,7 @@ import { } from "./egg-we-control"; import { LogitechHidppClient } from "./logitech-hidpp"; import type { MouseStatus } from "./mouse-types"; +import type { EggOp1Status } from "./egg-op1-protocol"; import { PulsarHidClient } from "./pulsar-hid"; import { PulsarProHidClient } from "./pulsar-pro-hid"; import { SUPPORTED_HID_FILTERS } from "./vendors"; @@ -304,7 +305,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.
@@ -442,6 +441,9 @@ function renderControl(): void { 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); @@ -763,6 +765,8 @@ function showStatus(status: MouseStatus): void { setControlValue("#right-spdt-select", eggStatus.rightSpdtMode); setControlValue("#egg-cpi-levels", eggStatus.eggCpiLevels); setControlValue("#egg-polling-divider", eggStatus.eggPollingDivider); + const leftHanded = document.querySelector("#egg-left-handed"); + if (leftHanded) leftHanded.checked = eggStatus.eggLeftHanded; updateCustomPollingPreview(); renderEggCpiStages(eggStatus); renderEggButtons(eggStatus); @@ -1552,7 +1556,7 @@ function renderEggCpiStages(status: EggOp1Status): void { const split = stage.x !== stage.y; const step = stage.x <= 10_000 ? status.eggCpiStepLow : status.eggCpiStepHigh; return `
- +
@@ -1593,31 +1597,117 @@ 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; @@ -1637,6 +1727,10 @@ 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) => { await client.setCpiStage(level, client.clampDpi(x), client.clampDpi(y)); @@ -1651,8 +1745,13 @@ 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); + }); } 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 049d10d..e908cfe 100644 --- a/src/egg-op1-hid.ts +++ b/src/egg-op1-hid.ts @@ -1,5 +1,6 @@ import type { MouseStatus } from "./mouse-types"; import { + EGG_BUTTON_ACTION_OPTIONS, EGG_COMMAND_SIZE, EGG_CONFIG_SIZE, EGG_DEVICE_PROFILES, @@ -8,15 +9,22 @@ import { 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"; @@ -35,13 +43,9 @@ const SPDT = { } 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; @@ -118,6 +122,10 @@ export class EggOp1HidClient { 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: this.profile.name, @@ -151,12 +159,15 @@ export class EggOp1HidClient { eggPollingDivider: config[EGG_OFFSET.pollingDivider], eggLodIndex: lodIndex, eggLodOptions: [...lodOptions], - eggMulticlickFilters: EGG_BUTTON_NAMES.map((_, index) => { - const value = config[EGG_OFFSET.firstButton + index * BUTTON_CONFIG_SIZE]; + 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)), + eggButtonMappings: buttonActions.map(eggButtonActionLabel), + eggButtonActions: buttonActions, + eggLeftHanded: leftHanded, liftOffDistance: this.genericLod(lodOptions[lodIndex]), firmware: this.firmwareVersion ? [`Firmware ${this.firmwareVersion}`] : ["Firmware unavailable"], }; @@ -289,7 +300,8 @@ export class EggOp1HidClient { 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 = EGG_OFFSET.firstButton + button * BUTTON_CONFIG_SIZE; + 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.`); @@ -299,16 +311,37 @@ export class EggOp1HidClient { if (confirmed[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 = EGG_OFFSET.firstButton + button * BUTTON_CONFIG_SIZE + 1; - const encoded = this.encodeButtonMapping(mapping); + 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) => { - config.fill(0, offset, offset + 6); - config[offset] = encoded.type; - config[offset + 1] = encoded.value; + 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] }); + } }); - const actual = this.decodeButtonMapping(confirmed, button); - if (actual !== mapping) throw new Error(`The mouse kept the ${EGG_BUTTON_NAMES[button]} mapping as ${actual}.`); + if (this.configIsLeftHanded(confirmed) !== enabled) throw new Error("The mouse did not confirm left-handed mode."); } async close(): Promise { @@ -347,29 +380,51 @@ export class EggOp1HidClient { return "Off"; } - private decodeButtonMapping(config: Uint8Array, button: EggButtonIndex): string { - const offset = EGG_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)}`; - } - - 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: 0xf1 }; - return { type: 0xff, value: 0 }; + 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 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 genericLod(label: string | undefined): MouseStatus["liftOffDistance"] { diff --git a/src/egg-op1-protocol.test.ts b/src/egg-op1-protocol.test.ts index f60d97d..42c9b68 100644 --- a/src/egg-op1-protocol.test.ts +++ b/src/egg-op1-protocol.test.ts @@ -3,8 +3,12 @@ import test from "node:test"; import { EGG_DEVICE_PROFILES, + eggButtonControlOffset, + eggButtonMappingOffset, eggClampCpi, + eggDecodeButtonAction, eggDpiOptions, + eggEncodeButtonAction, eggFormatFirmwareVersion, eggIsValidCpi, eggLodOptions, @@ -69,3 +73,27 @@ test("firmware response normalization preserves an included report ID", () => { 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 index cd4582f..4406faa 100644 --- a/src/egg-op1-protocol.ts +++ b/src/egg-op1-protocol.ts @@ -1,5 +1,21 @@ 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 }>; @@ -13,6 +29,8 @@ export interface EggOp1Status extends MouseStatus { eggLodOptions: string[]; eggMulticlickFilters: number[]; eggButtonMappings: string[]; + eggButtonActions: EggButtonAction[]; + eggLeftHanded: boolean; } export const EGG_VENDOR_ID = 0x3367; @@ -133,6 +151,7 @@ export const EGG_OFFSET = { activeCpiStage: 29, cpiLevels: 30, firstCpiSplit: 51, + handedButton: 71, firstButton: 77, glassMode: 127, angleTuning: 128, @@ -224,3 +243,133 @@ export function eggWriteUint16LE(bytes: Uint8Array, offset: number, value: numbe 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; +} From 202446391db5853cc75768b308cf411a686e4ab9 Mon Sep 17 00:00:00 2001 From: qsxcv Date: Sat, 1 Aug 2026 04:15:26 -0400 Subject: [PATCH 3/3] feat(egg): add PAW3950 and v2 sensor controls Expose the sensor controls that differ across the supported EGG 8K models while retaining the restrictions required by PAW3395 devices. - Add Glass Mode for OP1 8K Purple Frost, OP1 8K v2, and XM2 8K v2. - Add sensor angle tuning, maximum sensor FPS, and lift-off LED control for v2 models. - Disable the custom polling divider while Glass Mode controls it. - Block Motion Sync at 8K only on PAW3395 OP1 8K and XM2 8K models. - Keep Motion Sync available at 8K on PAW3950 models. - Add config reload and confirmed factory reset actions. --- src/control.ts | 77 ++++++++++++++++++++++++++++++++++++++++- src/egg-op1-hid.ts | 75 ++++++++++++++++++++++++++++++++++++++- src/egg-op1-protocol.ts | 7 ++++ 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/src/control.ts b/src/control.ts index 9c5b27e..9f94816 100644 --- a/src/control.ts +++ b/src/control.ts @@ -315,7 +315,9 @@ function renderControl(): void {

POWER

Auto sleep

SENSOR

Processing

Motion Sync
Angle snapping
Ripple control
Performance mode
+ + @@ -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); }); @@ -624,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); @@ -753,11 +772,20 @@ 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 (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); @@ -765,6 +793,28 @@ function showStatus(status: MouseStatus): void { 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(); @@ -819,7 +869,7 @@ function showStatus(status: MouseStatus): void { const hideListed = (status.brand === "Logitech" || ui?.hideUnsupportedPollingRates) && unsupportedForListed; 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" @@ -1754,6 +1804,31 @@ async function applyEggButtonMapping(button: EggButtonIndex, action: EggButtonAc }); } +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 { if (!activeEggClient || settingInProgress) return; settingInProgress = true; diff --git a/src/egg-op1-hid.ts b/src/egg-op1-hid.ts index e908cfe..fdfb188 100644 --- a/src/egg-op1-hid.ts +++ b/src/egg-op1-hid.ts @@ -159,6 +159,19 @@ export class EggOp1HidClient { 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}.`); @@ -195,6 +208,9 @@ export class EggOp1HidClient { } const divider = 8000 / rate; 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; }); @@ -226,6 +242,41 @@ export class EggOp1HidClient { 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.profile.motionSyncAt8k) { const config = await this.readConfig(); @@ -294,7 +345,12 @@ export class EggOp1HidClient { 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."); - const confirmed = await this.updateConfig((config) => { config[EGG_OFFSET.pollingDivider] = 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."); } @@ -344,6 +400,15 @@ export class EggOp1HidClient { 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."); + }); + } + async close(): Promise { this.onDeviceChange = undefined; this.device.removeEventListener("inputreport", this.onInputReport); @@ -358,6 +423,14 @@ export class EggOp1HidClient { } } + 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 { 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}.`); diff --git a/src/egg-op1-protocol.ts b/src/egg-op1-protocol.ts index 4406faa..5b9797b 100644 --- a/src/egg-op1-protocol.ts +++ b/src/egg-op1-protocol.ts @@ -29,6 +29,13 @@ export interface EggOp1Status extends MouseStatus { eggLodOptions: string[]; eggMulticlickFilters: number[]; eggButtonMappings: string[]; + eggGlassMode: boolean; + eggSupportsGlassMode: boolean; + eggMotionSyncAt8k: boolean; + eggAngleTuning?: number; + eggForceMaxFps?: boolean; + eggLedLiftOffDisabled?: boolean; + eggSupportsV2SensorControls: boolean; eggButtonActions: EggButtonAction[]; eggLeftHanded: boolean; }