From 587db98fa00a399798d8c0e95985af74b30d682d Mon Sep 17 00:00:00 2001 From: qsxcv Date: Sat, 1 Aug 2026 04:10:38 -0400 Subject: [PATCH 01/12] 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 48f5916e..0bdac19c 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 c2085209..98cf20e6 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 049d10d7..e908cfe7 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 f60d97df..42c9b687 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 cd4582ff..4406faa9 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 03/12] 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 9c5b27ed..9f94816b 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 e908cfe7..fdfb1888 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 4406faa9..5b9797b9 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; } From 74e9a19566b15d3fd64b6d17e7a5b39b5c7ce2ba Mon Sep 17 00:00:00 2001 From: Antiparty Date: Sun, 2 Aug 2026 19:26:07 -0700 Subject: [PATCH 04/12] feat: redesign OpenMouse interface --- control-app.html | 4 +- control.html | 4 +- demo.html | 4 +- index.html | 4 +- package.json | 3 +- src/control-gate.ts | 37 +- src/control.css | 128 ++++- src/control.ts | 79 +-- src/demo.css | 151 +++--- src/demo.ts | 2 +- src/landing-page.ts | 244 +++++++++ src/main.ts | 371 +------------- src/styles.css | 973 ++++++++++++++++++++++++------------ src/theme.css | 20 + tests/landing-page.test.mjs | 66 +++ 15 files changed, 1252 insertions(+), 838 deletions(-) create mode 100644 src/landing-page.ts create mode 100644 src/theme.css create mode 100644 tests/landing-page.test.mjs diff --git a/control-app.html b/control-app.html index 89d69126..1a80ff3a 100644 --- a/control-app.html +++ b/control-app.html @@ -3,11 +3,11 @@ - + - + OpenMouse Control diff --git a/control.html b/control.html index a6e2a286..17de87a7 100644 --- a/control.html +++ b/control.html @@ -3,11 +3,11 @@ - + - + OpenMouse License diff --git a/demo.html b/demo.html index 221a6cae..0c9770f5 100644 --- a/demo.html +++ b/demo.html @@ -3,13 +3,13 @@ - + - + diff --git a/index.html b/index.html index e80fd1a4..7e8f9712 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,10 @@ - + - + diff --git a/package.json b/package.json index 0bdac19c..f5e178f0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "test": "node --test src/egg-we-protocol.test.ts src/egg-op1-protocol.test.ts", - "preview": "vite preview" + "preview": "vite preview", + "test:landing": "node --test tests/landing-page.test.mjs" }, "devDependencies": { "typescript": "^5.8.3", diff --git a/src/control-gate.ts b/src/control-gate.ts index 994b7bc1..3f5bce49 100644 --- a/src/control-gate.ts +++ b/src/control-gate.ts @@ -16,25 +16,24 @@ function escapeMarkup(value: string): string { function renderGate(message = ""): void { appRoot.innerHTML = ` - -
-
- OpenMouse - ${__BUILD_CHANNEL__.toUpperCase()} · v${__APP_VERSION__} -
-

PRIVATE CONTROL PANEL

-

Enter your license.

-

Use your OpenMouse license code to unlock device control.

-
- - - - ${escapeMarkup(message)} -
+
+
+ OpenMouse + ${__BUILD_CHANNEL__.toUpperCase()} · v${__APP_VERSION__} +
+
+ +

Private control panel

+

Enter your license.

+

Use your OpenMouse license code to unlock verified device controls in this browser.

+
+ + + + ${escapeMarkup(message)} +
+
+

Your code is used only to validate access to the private control panel.

`; document.querySelector("#access-form")?.addEventListener("submit", async (event) => { diff --git a/src/control.css b/src/control.css index cf86b813..1aeed2e8 100644 --- a/src/control.css +++ b/src/control.css @@ -1,24 +1,21 @@ @import "./demo.css"; -.access-gate { - display: flex; - min-height: 100vh; - align-items: flex-start; - justify-content: center; - flex-direction: column; - width: min(100% - 3rem, 520px); - margin: 0 auto; -} - -.access-gate .demo-wordmark { margin-bottom: 5rem; } -.access-gate h1 { margin: 0; font-size: clamp(2.8rem, 8vw, 4.6rem); font-weight: 500; letter-spacing: -.04em; line-height: .92; } -.access-gate > p:not(.overline) { max-width: 420px; color: #96969b; line-height: 1.6; } -.access-form { display: grid; width: min(100%, 360px); gap: .6rem; margin-top: 1.4rem; } -.access-form label { color: #96969b; font-size: .72rem; } -.access-form input { padding: .8rem .9rem; border: 1px solid #36363a; border-radius: 8px; outline: none; background: #161618; color: #f7f7f7; } -.access-form input:focus { border-color: #f7f7f7; } -.access-form button { padding: .8rem .9rem; border: 0; border-radius: 8px; background: #f7f7f7; color: #09090b; font-weight: 600; } -.access-form output { min-height: 1.2rem; color: #ee8c85; font-size: .72rem; } +.access-shell { display: grid; width: min(calc(100% - 2rem), 760px); min-height: 100vh; margin: 0 auto; padding: 1.5rem 0; grid-template-rows: auto 1fr auto; } +.access-header { display: flex; align-items: center; justify-content: space-between; min-height: 48px; } +.access-header .demo-wordmark { margin: 0; } +.build-meta { color: var(--muted); font-family: inherit; font-size: .58rem; font-weight: 600; letter-spacing: -0.15px; line-height: 1; text-transform: uppercase; white-space: nowrap; } +.access-gate { align-self: center; width: min(100%, 540px); padding: clamp(2rem, 7vw, 4rem); border: 1px solid var(--border); border-radius: var(--radius-lg); background: radial-gradient(circle at 0 0, rgb(255 255 255 / 7%), transparent 38%), var(--surface-deep); box-shadow: 0 30px 80px rgb(0 0 0 / 32%); } +.access-mark { display: grid; width: 42px; height: 42px; margin-bottom: 3.5rem; place-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font-size: .68rem; font-weight: 600; } +.access-gate h1 { margin: 0; font-size: clamp(2.8rem, 8vw, 4.6rem); font-weight: 500; letter-spacing: -0.15px; line-height: .94; } +.access-gate > p:not(.overline) { max-width: 420px; margin: 1.25rem 0 0; color: var(--muted); line-height: 1.65; } +.access-form { display: grid; width: 100%; gap: .65rem; margin-top: 2rem; } +.access-form label { color: var(--muted); font-size: .7rem; } +.access-form input { min-height: 46px; padding: 0 .9rem; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); outline: none; background: var(--surface); color: var(--text); } +.access-form input:focus { border-color: var(--text); box-shadow: 0 0 0 3px rgb(255 255 255 / 7%); } +.access-form button { min-height: 46px; padding: 0 .9rem; border: 0; border-radius: var(--radius-sm); background: var(--text); color: var(--background); font-weight: 600; } +.access-form button:disabled { cursor: wait; opacity: .65; } +.access-form output { min-height: 1.2rem; color: var(--negative); font-size: .7rem; line-height: 1.5; } +.access-footnote { align-self: end; margin: 1rem 0 0; color: var(--muted); font-size: .64rem; text-align: center; } .control-shell { display: grid; grid-template-columns: 260px minmax(0, 1fr); min-height: 100vh; } .device-select { cursor: default; } .setting-card button:disabled { cursor: default; opacity: 1; } @@ -93,3 +90,96 @@ .control-shell .sidebar-footer { display: none; } .control-shell .control-panel { width: min(100% - 2rem, 1180px); } } + +/* Neutral precision control surface */ +.control-shell { background: var(--background); color: var(--text); } +.control-shell .sidebar { border-color: var(--border); background: var(--surface-deep); } +.control-shell .device-select, +.control-shell .preview-banner, +.control-shell .setting-card, +.control-shell .panel-footer, +.control-shell .egg-collapsible, +.control-shell .egg-experimental, +.control-shell .interface-setting-card { border-color: var(--border); background: var(--surface-deep); } +.control-shell .device-overview { border-color: var(--border); border-radius: var(--radius-lg); background: var(--surface-deep); } +.control-shell .summary-stat, +.control-shell .quick-stats, +.control-shell .toggle-row, +.control-shell .interface-settings-header, +.control-shell .egg-collapsible-body, +.control-shell .egg-experimental-body { border-color: var(--border) !important; } +.control-shell .setting-card, +.control-shell .egg-collapsible, +.control-shell .egg-experimental, +.control-shell .interface-setting-card { border-radius: var(--radius-md) !important; } +.control-shell .preview-banner, +.control-shell .panel-footer, +.control-shell .sidebar-action, +.control-shell .empty-connect-action, +.control-shell .setting-action button, +.control-shell .dpi-header-actions button, +.control-shell .dpi-header-actions input, +.control-shell .segmented button, +.control-shell .egg-action-button, +.control-shell [data-apply-cpi], +.control-shell .interface-settings-back, +.control-shell .interface-reset, +.control-shell input:not([type="checkbox"]):not([type="range"]), +.control-shell select { border-radius: var(--radius-sm) !important; } +.control-shell .sidebar-action, +.control-shell .dpi-header-actions button, +.control-shell .dpi-header-actions input, +.control-shell .segmented button, +.control-shell .egg-action-button, +.control-shell [data-apply-cpi], +.control-shell .interface-settings-back, +.control-shell input:not([type="checkbox"]):not([type="range"]), +.control-shell select { border-color: var(--border-strong) !important; background: var(--surface) !important; color: var(--text) !important; } +.control-shell .sidebar-action:hover, +.control-shell .segmented button:hover, +.control-shell .egg-action-button:hover, +.control-shell [data-apply-cpi]:hover { background: var(--surface-hover) !important; } +.control-shell .segmented button.selected, +.control-shell .setting-action button:not(:disabled), +.control-shell .dpi-header-actions button:not(:disabled) { border-color: var(--ui-accent) !important; background: var(--ui-accent) !important; color: var(--ui-accent-ink) !important; } +.control-shell .device-label, +.control-shell .overline, +.control-shell .summary-stat > span, +.control-shell .setting-heading p, +.control-shell .setting-note, +.control-shell .range-labels, +.control-shell .sidebar-footer, +.control-shell .panel-footer, +.control-shell .interface-setting-card > span, +.control-shell .interface-setting-card p { color: var(--muted); font-family: inherit; letter-spacing: -0.15px; } +.control-shell .panel-header h1, +.control-shell .empty-state h2, +.control-shell .interface-settings-header h2 { letter-spacing: -0.15px; } +.control-shell .interface-settings-page { background: var(--background); } +.control-shell.is-empty .empty-state { border-color: var(--border); border-radius: var(--radius-lg); background: radial-gradient(circle at 76% 48%, var(--ui-accent-soft), transparent 23%), linear-gradient(135deg, var(--surface), var(--surface-deep) 68%); } +.control-shell .device-dot.is-idle, +.control-shell .status-dot.is-idle { background: var(--muted); } +.control-shell .panel-footer #read-status, +.control-shell .device-status, +.control-shell .empty-state > p:not(.overline), +.control-shell .empty-state small { color: var(--muted); } +.control-scroll-region { position: relative; overflow-y: auto; } +.compact-action { align-self: flex-start; min-height: 28px; margin-top: .45rem; padding: 0 .55rem; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); font-size: .61rem; font-weight: 600; } +.device-detail-item, +.device-field-card { min-width: 0; padding: .6rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); } +.device-detail-item small { display: block; margin-bottom: .25rem; color: var(--muted); font-size: .52rem; } +.device-detail-item span { color: var(--text); font-size: .67rem; font-weight: 600; overflow-wrap: anywhere; } +.device-field-card > strong { font-size: .7rem; } +.device-field-card label { display: block; margin-top: .4rem; color: var(--muted); font-size: .58rem; } +.device-field-card input:not([type="checkbox"]), +.device-field-card select { width: 100%; min-height: 34px; box-sizing: border-box; margin-top: .18rem; padding: 0 .45rem; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-deep); color: var(--text); } +.device-field-card .check-field { display: flex; align-items: center; gap: .35rem; font-size: .62rem; } +.paired-fields { display: grid; grid-template-columns: 1fr 1fr; gap: .35rem; } +.device-field-card [data-apply-cpi] { min-height: 30px; margin-top: .45rem; padding: 0 .55rem; } + +@media (max-width: 560px) { + .access-shell { padding-top: 1rem; } + .access-header { align-items: flex-start; gap: .75rem; } + .access-gate { padding: 1.5rem; } + .access-mark { margin-bottom: 2.5rem; } +} diff --git a/src/control.ts b/src/control.ts index 9f94816b..48991a32 100644 --- a/src/control.ts +++ b/src/control.ts @@ -142,7 +142,7 @@ function renderControl(): void { .control-shell { --ui-accent:#69d28d;--ui-accent-ink:#07120b;--ui-accent-soft:rgb(105 210 141 / 16%) } .build-identity { display:flex;align-items:center;gap:.65rem;margin-bottom:4rem } .build-identity .demo-wordmark { margin-bottom:0 } - .build-badge { display:inline-flex;align-items:center;min-height:1.35rem;padding:.2rem .45rem;border:1px solid color-mix(in srgb,var(--ui-accent) 35%,transparent);border-radius:999px;background:var(--ui-accent-soft);color:var(--ui-accent);font-family:"JetBrains Mono",ui-monospace,monospace;font-size:.52rem;font-weight:700;letter-spacing:.07em;line-height:1;white-space:nowrap } + .build-meta { color:var(--muted);font-family:inherit;font-size:.52rem;font-weight:700;letter-spacing:-.15px;line-height:1;text-transform:uppercase;white-space:nowrap } .control-shell[data-interface-theme="violet"] { --ui-accent:#a78bfa;--ui-accent-ink:#130b25;--ui-accent-soft:rgb(167 139 250 / 17%) } .control-shell[data-interface-theme="ice"] { --ui-accent:#67d8ff;--ui-accent-ink:#06161d;--ui-accent-soft:rgb(103 216 255 / 16%) } .control-shell[data-interface-theme="ember"] { --ui-accent:#ff9b62;--ui-accent-ink:#211006;--ui-accent-soft:rgb(255 155 98 / 17%) } @@ -213,7 +213,7 @@ function renderControl(): void { #egg-cpi-stage-list, #egg-button-list { grid-template-columns:1fr !important } } .interface-settings-button::before { content:"⚙";font-size:.72rem } - .interface-settings-page { position:absolute;z-index:20;inset:0;display:none;padding:1rem 0 2rem;overflow:auto;background:#0b0b0d;scrollbar-width:none } + .interface-settings-page { position:absolute;z-index:20;inset:0;display:none;padding:1rem 0 2rem;overflow:auto;background:var(--background);scrollbar-width:none } .interface-settings-page::-webkit-scrollbar { display:none } .interface-settings-page.is-open { display:block } .interface-settings-header { display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;padding:1rem 0 1.2rem;border-bottom:1px solid #29292d } @@ -226,7 +226,7 @@ function renderControl(): void { .interface-setting-card p { margin:0 0 .8rem;color:#85858a;font-size:.68rem;line-height:1.45 } .interface-setting-card select { width:100%;padding:.52rem;border:1px solid #39393e;border-radius:7px;background:#18181b;color:#eee;color-scheme:dark } .theme-preview { display:flex;gap:.32rem;margin-top:.65rem } - .theme-preview i { width:1.25rem;height:.28rem;border-radius:999px;background:var(--ui-accent);opacity:.2 } + .theme-preview i { width:1.25rem;height:2px;border-radius:0;background:var(--ui-accent);opacity:.2 } .theme-preview i:nth-child(2) { opacity:.35 }.theme-preview i:nth-child(3) { opacity:.55 }.theme-preview i:nth-child(4) { opacity:.75 }.theme-preview i:nth-child(5) { opacity:1 } .interface-switch-row { display:flex;align-items:center;justify-content:space-between;gap:.8rem;margin-top:.7rem;color:#c5c5c9;font-size:.72rem } .interface-switch-row input { @@ -236,7 +236,7 @@ function renderControl(): void { flex:0 0 auto; margin:0; border:1px solid #414147; - border-radius:999px; + border-radius:var(--radius-sm); outline:none; background-color:#242428; background-image:radial-gradient(circle at .56rem 50%,#a0a0a5 0 .34rem,transparent .36rem); @@ -249,6 +249,11 @@ function renderControl(): void { background-image:radial-gradient(circle at calc(100% - .56rem) 50%,#07120b 0 .34rem,transparent .36rem); } .interface-switch-row input:focus-visible { box-shadow:0 0 0 3px var(--ui-accent-soft) } + .binary-setting-row { display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.2rem 0;color:#b3b3b7;font-size:.7rem } + .binary-switch { min-width:42px;padding:.28rem .5rem;border:1px solid var(--border-strong);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);font-size:.58rem } + .binary-switch[aria-checked="true"] { border-color:var(--ui-accent);background:var(--ui-accent);color:var(--ui-accent-ink) } + .binary-switch:disabled { border-color:var(--border);background:var(--surface-deep);color:#66666b } + .binary-switch:focus-visible { outline:2px solid var(--ui-accent);outline-offset:2px } .interface-reset { margin-top:.75rem;padding:.55rem .75rem;border:1px solid #4a3436;border-radius:7px;background:#1c1315;color:#e7a7aa;font-size:.68rem;font-weight:650 } .density-comfortable #pulsar-advanced.egg-advanced-layout { gap:.8rem !important } .density-comfortable #pulsar-advanced.egg-advanced-layout > .setting-card { padding:1rem !important } @@ -269,7 +274,7 @@ function renderControl(): void { -
+
WEBHID

Connect a supported device to view and change its settings.

DEVICE CONTROL

Connect a mouse

@@ -299,7 +304,7 @@ function renderControl(): void {
BATTERYRead after connection
FIRMWARERead after connection
-
CONNECTION2.4 GHz receiver
+
CONNECTION2.4 GHz receiver

DPI

Sensitivity

Choose a DPI value
@@ -312,16 +317,31 @@ function renderControl(): void {
LIVE STATUSAdd a supported device from the sidebar to read its current status.
@@ -916,7 +936,7 @@ function renderLogitechDetails(status: MouseStatus): void { ["DPI axes", status.supportsSeparateDpiAxes ? `X ${status.dpi} · Y ${status.dpiY ?? status.dpi}` : "Linked X/Y"], ]; list.innerHTML = items.map(([label, value]) => - `
${label.toUpperCase()}${value}
`).join(""); + `
${label.toUpperCase()}${value}
`).join(""); } function setControlValue(selector: string, value: number | string | null | undefined): void { @@ -932,17 +952,12 @@ function setToggleValue(selector: string, value: boolean | null | undefined): vo if (!control) return; control.disabled = value === null || value === undefined; if (control.disabled) { + control.removeAttribute("aria-checked"); control.textContent = "N/A"; - control.style.background = "#202023"; - control.style.borderColor = "#3a3a3f"; - control.style.color = "#66666b"; return; } control.setAttribute("aria-checked", String(value)); control.textContent = value ? "On" : "Off"; - control.style.background = value ? "var(--ui-accent)" : "#202023"; - control.style.borderColor = value ? "var(--ui-accent)" : "#3a3a3f"; - control.style.color = value ? "var(--ui-accent-ink)" : "#8b8b90"; } function formatHex(value: number, width = 2): string { @@ -1605,14 +1620,14 @@ function renderEggCpiStages(status: EggOp1Status): void { 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 `
- - -
- - + return `
+ + +
+ +
- +
`; }).join(""); container.querySelectorAll("[data-active-cpi]").forEach((radio) => { @@ -1657,8 +1672,8 @@ function renderEggButtons(status: EggOp1Status): void { 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 ? "" : `