From 643a456dc210edc5618472f885360c654fd8a5f5 Mon Sep 17 00:00:00 2001 From: user-vtp2 Date: Tue, 25 Aug 2026 19:29:40 +0200 Subject: [PATCH] Add Glorious Model O 2 / I 2 family support (write-only Pixart protocol) Pure codec (src/glorious/index.ts): settings and lighting payload builders, DPI/polling/LOD/debounce normalization and validation, extracted from openmouse-main's glorious-protocol.ts. No WebHID or DOM dependencies. WebHID driver (src/drivers/glorious/hid.ts): GloriousHidClient with isSupported collection probing, local state caching for the write-only protocol, and UI hints that hide LOD-Low, unsupported polling rates, and the processing card. Registry: Glorious VID 0x093a added to vendors.ts, SUPPORTED_HID_FILTERS, and DEVICE_DRIVERS. MouseStatus brand union extended with 'Glorious'. 13 protocol tests ported from openmouse-main's glorious-protocol.test.ts, all passing. --- package.json | 4 + src/drivers/glorious/hid.ts | 241 +++++++++++++++++++++ src/drivers/glorious/protocol.test.ts | 196 +++++++++++++++++ src/drivers/mouse-types.ts | 2 +- src/drivers/registry.ts | 4 +- src/drivers/vendors.ts | 12 ++ src/glorious/index.ts | 294 ++++++++++++++++++++++++++ src/index.ts | 1 + 8 files changed, 752 insertions(+), 2 deletions(-) create mode 100644 src/drivers/glorious/hid.ts create mode 100644 src/drivers/glorious/protocol.test.ts create mode 100644 src/glorious/index.ts diff --git a/package.json b/package.json index bf45cd5..82f21e5 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,10 @@ "./gwolves": { "types": "./dist/gwolves/index.d.ts", "import": "./dist/gwolves/index.js" + }, + "./glorious": { + "types": "./dist/glorious/index.d.ts", + "import": "./dist/glorious/index.js" } }, "scripts": { diff --git a/src/drivers/glorious/hid.ts b/src/drivers/glorious/hid.ts new file mode 100644 index 0000000..9f9c38d --- /dev/null +++ b/src/drivers/glorious/hid.ts @@ -0,0 +1,241 @@ +import type { GloriousLighting, GloriousSettings } from "../../glorious/index.ts"; +import { + GLORIOUS_CONFIG_REPORT_ID, + GLORIOUS_DEFAULT_LIGHTING, + GLORIOUS_DEFAULT_SETTINGS, + GLORIOUS_DEBOUNCE_MAX_MS, + GLORIOUS_DPI_MAX, + GLORIOUS_DPI_MIN, + GLORIOUS_DPI_UNIT, + GLORIOUS_POLLING_RATES, + buildGloriousLightingPayload, + buildGloriousSettingsPayload, + gloriousDecodePolling, + gloriousEncodePolling, + gloriousIsSupportedDpi, + gloriousNormalizeLighting, + gloriousNormalizeSettings, + gloriousSanitizeDebounce, +} from "../../glorious/index.ts"; +import type { MouseStatus, MouseUiHints } from "../mouse-types.ts"; +import { VENDOR_ID, GLORIOUS_PRODUCTS } from "../vendors.ts"; + +/** + * Driver for the write-only Pixart configuration interface of the Glorious + * Model O 2 / I 2 family. See glorious/index.ts for the payload layout; + * this file only handles HID plumbing, validation, and local state caching. + */ + +const VENDOR_USAGE_PAGE = 0xff00; + +type LiftOffDistance = NonNullable; + +const LIFT_OFF_DISTANCES: ReadonlyArray = [ + [1, "Medium"], + [2, "High"], +]; + +export class GloriousHidClient { + /** Write-only protocol — there is nothing to poll. */ + readonly pollIntervalMs = 0; + readonly device: HIDDevice; + + constructor(device: HIDDevice) { + this.device = device; + } + + static isSupported(device: HIDDevice): boolean { + if (device.vendorId !== VENDOR_ID.glorious) return false; + const named = GLORIOUS_PRODUCTS.has(device.productId) + || /model\s+[odi]\s*2/i.test(device.productName || ""); + if (!named) return false; + return device.collections.some((collection) => this.hasConfigReport(collection)); + } + + private static hasConfigReport(collection: HIDCollectionInfo): boolean { + return collection.usagePage === VENDOR_USAGE_PAGE + && collection.featureReports.some((report) => report.reportId === GLORIOUS_CONFIG_REPORT_ID) + || collection.children.some((child) => this.hasConfigReport(child)); + } + + async open(): Promise { + if (!this.device.opened) await this.device.open(); + } + + async close(): Promise { + if (this.device.opened) await this.device.close(); + } + + displayName(): string { + if (this.device.productName) return this.device.productName; + const known = GLORIOUS_PRODUCTS.get(this.device.productId); + return known ? `Glorious ${known.name}` : "Glorious mouse"; + } + + isWireless(): boolean { + return GLORIOUS_PRODUCTS.get(this.device.productId)?.wireless ?? false; + } + + getDpiOptions(): number[] { + const options: number[] = []; + for (let dpi = GLORIOUS_DPI_MIN; dpi <= GLORIOUS_DPI_MAX; dpi += GLORIOUS_DPI_UNIT) options.push(dpi); + return options; + } + + getSupportedPollingRates(): number[] { + return GLORIOUS_POLLING_RATES.map(([, hertz]) => hertz).sort((left, right) => left - right); + } + + async readStatus(): Promise { + await this.open(); + return this.statusFromState(this.loadState()); + } + + async setDpi(dpi: number): Promise { + if (!gloriousIsSupportedDpi(dpi)) throw new Error(`${dpi.toLocaleString()} is not a supported DPI value.`); + const settings = this.loadState(); + settings.stageDpis[settings.activeStage] = dpi; + await this.pushSettings(settings); + return dpi; + } + + async setPollingRate(pollingRateHz: number): Promise { + const encoded = gloriousEncodePolling(pollingRateHz); + if (!encoded || !this.getSupportedPollingRates().includes(pollingRateHz)) { + throw new Error(`This mouse does not support ${pollingRateHz} Hz.`); + } + const settings = this.loadState(); + settings.pollingCode = encoded; + await this.pushSettings(settings); + return pollingRateHz; + } + + async setLiftOffDistance(value: LiftOffDistance): Promise { + const millimetres = LIFT_OFF_DISTANCES.find(([, name]) => name === value)?.[0]; + if (!millimetres) throw new Error(`This mouse does not support a ${value.toLowerCase()} lift-off distance.`); + const settings = this.loadState(); + settings.lodMm = millimetres; + await this.pushSettings(settings); + return value; + } + + async setDebounceTime(milliseconds: number): Promise { + if (!Number.isInteger(milliseconds)) { + throw new Error(`Debounce must be an even number of milliseconds between 0 and ${GLORIOUS_DEBOUNCE_MAX_MS}.`); + } + const settings = this.loadState(); + settings.debounceMs = gloriousSanitizeDebounce(milliseconds); + await this.pushSettings(settings); + return settings.debounceMs; + } + + async setSleepTimeout(_seconds: number): Promise { + throw new Error("Auto sleep is not exposed by this device's protocol."); + } + + /** Last known onboard settings (write-only protocol: nothing to poll). */ + getSettings(): GloriousSettings { + return this.loadState(); + } + + /** Validates and pushes a full settings payload, returning the stored copy. */ + async applySettings(settings: GloriousSettings): Promise { + const normalized = gloriousNormalizeSettings(settings); + await this.pushSettings(normalized); + return this.loadState(); + } + + /** Firmware never reports lighting back; this returns the last applied state. */ + getLighting(): GloriousLighting { + try { + return gloriousNormalizeLighting(JSON.parse(localStorage.getItem(this.lightingKey()) ?? "{}")); + } catch { + return { ...GLORIOUS_DEFAULT_LIGHTING, colors: [...GLORIOUS_DEFAULT_LIGHTING.colors] }; + } + } + + async setLighting(lighting: GloriousLighting): Promise { + await this.open(); + for (const fragment of buildGloriousLightingPayload(lighting)) { + await this.device.sendFeatureReport(GLORIOUS_CONFIG_REPORT_ID, fragment.slice(1)); + } + const normalized = gloriousNormalizeLighting(lighting); + try { + localStorage.setItem(this.lightingKey(), JSON.stringify(normalized)); + } catch { + // Lighting still reaches the mouse when browser storage is unavailable. + } + return normalized; + } + + getUiHints(): MouseUiHints { + return { + family: "glorious", + hideLodLow: true, + hideUnsupportedPollingRates: true, + hideProcessingCard: true, + }; + } + + private async pushSettings(settings: GloriousSettings): Promise { + await this.open(); + for (const fragment of buildGloriousSettingsPayload(settings)) { + // Fragments follow the C/hidapi layout where byte 0 is the report ID. + // WebHID takes the ID separately and prepends it, so only send the body. + await this.device.sendFeatureReport(GLORIOUS_CONFIG_REPORT_ID, fragment.slice(1)); + } + this.saveState(settings); + } + + private statusFromState(settings: GloriousSettings): MouseStatus { + const wireless = this.isWireless(); + const liftOffDistance = LIFT_OFF_DISTANCES.find(([millimetres]) => millimetres === settings.lodMm)?.[1] + ?? "Medium"; + return { + brand: "Glorious", + name: this.displayName(), + ui: this.getUiHints(), + batteryPercent: null, + batteryState: "Unknown", + dpi: settings.stageDpis[settings.activeStage] || 800, + pollingRateHz: gloriousDecodePolling(settings.pollingCode) ?? 1000, + supportedPollingRates: this.getSupportedPollingRates(), + activeProfile: settings.activeStage + 1, + connectionType: wireless ? "Wireless" : "Wired", + connectionDetail: wireless + ? "2.4 GHz receiver · write-only config" + : "Wired USB · write-only config", + debounceMs: settings.debounceMs, + liftOffDistance, + firmware: [], + }; + } + + private stateKey(): string { + return `openmouse-glorious-state-v1:${this.device.vendorId.toString(16)}-${this.device.productId.toString(16)}`; + } + + private lightingKey(): string { + return `openmouse-glorious-lighting-v1:${this.device.vendorId.toString(16)}-${this.device.productId.toString(16)}`; + } + + private loadState(): GloriousSettings { + try { + return gloriousNormalizeSettings(JSON.parse(localStorage.getItem(this.stateKey()) ?? "{}")); + } catch { + return { + ...GLORIOUS_DEFAULT_SETTINGS, + stageDpis: [...GLORIOUS_DEFAULT_SETTINGS.stageDpis], + stageColors: [...GLORIOUS_DEFAULT_SETTINGS.stageColors], + }; + } + } + + private saveState(settings: GloriousSettings): void { + try { + localStorage.setItem(this.stateKey(), JSON.stringify(settings)); + } catch { + // Settings still reach the mouse when browser storage is unavailable. + } + } +} diff --git a/src/drivers/glorious/protocol.test.ts b/src/drivers/glorious/protocol.test.ts new file mode 100644 index 0000000..9904165 --- /dev/null +++ b/src/drivers/glorious/protocol.test.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + GLORIOUS_DEFAULT_LIGHTING, + GLORIOUS_DEFAULT_STAGE_COLORS, + GLORIOUS_RGB_EFFECTS, + type GloriousLighting, + buildGloriousLightingPayload, + buildGloriousSettingsPayload, + gloriousDecodePolling, + gloriousEncodeDpi, + gloriousEncodePolling, + gloriousIsSupportedDpi, + gloriousLightingColorCount, + gloriousNormalizeLighting, + gloriousNormalizeSettings, + gloriousSanitizeDebounce, + type GloriousSettings, +} from "@openmouse/protocol/glorious"; + +function defaultSettings(): GloriousSettings { + return { + activeStage: 2, + stageCount: 4, + stageDpis: [400, 800, 1600, 3200, 0, 0], + stageColors: ["#ff0000", "#0000ff", "#00ff00", "#ffff00", "#000000", "#000000"], + lodMm: 1, + debounceMs: 10, + pollingCode: 0x01, + }; +} + +test("settings payload splits into four 64-byte fragments with headers", () => { + const fragments = buildGloriousSettingsPayload(defaultSettings()); + assert.equal(fragments.length, 4); + fragments.forEach((fragment, index) => { + assert.equal(fragment.length, 64); + assert.equal(fragment[0], 0x03); + assert.equal(fragment[1], 0x04); + assert.equal(fragment[2], 0xfb); + assert.equal(fragment[3], index); + assert.equal(fragment[4], 0x01); + }); +}); + +test("global settings land in fragment 0 at the documented offsets", () => { + const [first] = buildGloriousSettingsPayload(defaultSettings()); + assert.equal(first[5], 2); + assert.equal(first[6], 4); + assert.equal(first[7], 1); + assert.equal(first[8], 10); + assert.equal(first[9], 0x01); + assert.equal(first[10], 0x00); +}); + +test("stage DPIs are little-endian units of 50 across all fragments", () => { + const settings = { ...defaultSettings(), stageCount: 6, stageDpis: [400, 800, 1600, 3200, 6400, 12800] }; + const [f1, f2, f3, f4] = buildGloriousSettingsPayload(settings); + assert.deepEqual([f1[11], f1[12]], [8, 0]); + assert.deepEqual([f2[5], f2[6]], [16, 0]); + assert.deepEqual([f2[10], f2[11]], [32, 0]); + assert.deepEqual([f3[5], f3[6]], [64, 0]); + assert.deepEqual([f3[10], f3[11]], [128, 0]); + const encoded12800 = gloriousEncodeDpi(12800); + assert.deepEqual([f4[5], f4[6]], [encoded12800 & 0xff, encoded12800 >> 8 & 0xff]); +}); + +test("unused stages and factory colors are written verbatim", () => { + const [f1, f2, f3, f4] = buildGloriousSettingsPayload(defaultSettings()); + assert.deepEqual([...f1.slice(13, 16)], [0xff, 0x00, 0x00]); + assert.deepEqual([...f2.slice(7, 10)], [0x00, 0x00, 0xff]); + assert.deepEqual([...f2.slice(12, 15)], [0x00, 0xff, 0x00]); + assert.deepEqual([...f3.slice(7, 10)], [0xff, 0xff, 0x00]); + assert.deepEqual([...f3.slice(10, 13)], [0, 0, 0]); + assert.deepEqual([...f4.slice(7, 10)], [0, 0, 0]); +}); + +test("custom stage colors replace the factory palette byte for byte", () => { + const settings = { + ...defaultSettings(), + stageColors: ["#102030", "#405060", "#708090", "#a0b0c0", "#d0e0f0", "#ffffff"], + }; + const [f1, f2, f3, f4] = buildGloriousSettingsPayload(settings); + assert.deepEqual([...f1.slice(13, 16)], [0x10, 0x20, 0x30]); + assert.deepEqual([...f2.slice(7, 10)], [0x40, 0x50, 0x60]); + assert.deepEqual([...f2.slice(12, 15)], [0x70, 0x80, 0x90]); + assert.deepEqual([...f3.slice(7, 10)], [0xa0, 0xb0, 0xc0]); + assert.deepEqual([...f3.slice(12, 15)], [0xd0, 0xe0, 0xf0]); + assert.deepEqual([...f4.slice(7, 10)], [0xff, 0xff, 0xff]); +}); + +test("settings normalization repairs invalid stage colors and keeps valid ones", () => { + const normalized = gloriousNormalizeSettings({ + stageColors: ["#AABBCC", "red", undefined, "#12345"], + }); + assert.deepEqual( + normalized.stageColors.slice(0, 4), + ["#aabbcc", ...GLORIOUS_DEFAULT_STAGE_COLORS.slice(1, 3), "#ffff00"], + ); +}); + +test("polling codes round-trip through the documented mapping", () => { + for (const [code, hertz] of [[0x01, 1000], [0x02, 125], [0x03, 250], [0x04, 500]] as const) { + assert.equal(gloriousEncodePolling(hertz), code); + const decoded = gloriousDecodePolling(code); + assert.equal(decoded, hertz); + } + assert.equal(gloriousEncodePolling(2000), null); + assert.equal(gloriousDecodePolling(0x05), null); +}); + +test("debounce is clamped to even milliseconds within 0-16", () => { + assert.equal(gloriousSanitizeDebounce(10), 10); + assert.equal(gloriousSanitizeDebounce(7), 6); + assert.equal(gloriousSanitizeDebounce(99), 16); + assert.equal(gloriousSanitizeDebounce(-3), 0); +}); + +test("DPI validation accepts the advertised grid only", () => { + assert.equal(gloriousIsSupportedDpi(800), true); + assert.equal(gloriousIsSupportedDpi(26000), true); + assert.equal(gloriousIsSupportedDpi(801), false); + assert.equal(gloriousIsSupportedDpi(50), false); +}); + +function defaultLighting(): GloriousLighting { + return { + effect: GLORIOUS_RGB_EFFECTS.breathing, + brightnessWired: 0x14, + brightnessWireless: 0x0a, + speed: 0x05, + colors: ["#102030", "#405060", "#708090", "#a0b0c0", "#d0e0f0", "#ffffff", "#000000"], + }; +} + +test("lighting payload splits into three 64-byte fragments with 02 fb headers", () => { + const fragments = buildGloriousLightingPayload(defaultLighting()); + assert.equal(fragments.length, 3); + fragments.forEach((fragment, index) => { + assert.equal(fragment.length, 64); + assert.equal(fragment[0], 0x03); + assert.equal(fragment[1], 0x02); + assert.equal(fragment[2], 0xfb); + assert.equal(fragment[3], index); + assert.equal(fragment[4], 0x01); + }); +}); + +test("lighting fields land at the documented offsets and effects echo across fragments", () => { + const lighting = defaultLighting(); + const [first, second, third] = buildGloriousLightingPayload(lighting); + assert.equal(first[5], GLORIOUS_RGB_EFFECTS.breathing); + assert.equal(first[6], 0x0a); + assert.equal(first[7], 0x14); + assert.equal(first[8], 7); + assert.equal(first[9], 0x05); + assert.equal(first[10], 0x14); + for (const fragment of [first, second, third]) { + assert.equal(fragment[5], GLORIOUS_RGB_EFFECTS.breathing); + } +}); + +test("primary color sits in fragment 0 and cycle colors in fragment 1", () => { + const [first, second] = buildGloriousLightingPayload(defaultLighting()); + assert.deepEqual([...first.slice(11, 14)], [0x10, 0x20, 0x30]); + assert.deepEqual([...second.slice(6, 9)], [0x40, 0x50, 0x60]); + assert.deepEqual([...second.slice(21, 24)], [0x00, 0x00, 0x00]); +}); + +test("color count follows the effect rules", () => { + assert.equal(gloriousLightingColorCount(GLORIOUS_RGB_EFFECTS.normallyOn), 1); + assert.equal(gloriousLightingColorCount(GLORIOUS_RGB_EFFECTS.off), 1); + assert.equal(gloriousLightingColorCount(GLORIOUS_RGB_EFFECTS.breathingSingle), 1); + assert.equal(gloriousLightingColorCount(GLORIOUS_RGB_EFFECTS.rave), 2); + assert.equal(gloriousLightingColorCount(GLORIOUS_RGB_EFFECTS.glorious), 7); + assert.equal(gloriousLightingColorCount(GLORIOUS_RGB_EFFECTS.wave), 7); +}); + +test("lighting normalization repairs invalid persisted state", () => { + const normalized = gloriousNormalizeLighting({ + effect: 99, + brightnessWired: 3, + brightnessWireless: "nope", + speed: 200, + colors: ["#AABBCC", "red", undefined], + }); + assert.equal(normalized.effect, GLORIOUS_DEFAULT_LIGHTING.effect); + assert.equal(normalized.brightnessWired, 0x05); + assert.equal(normalized.brightnessWireless, 0x14); + assert.equal(normalized.speed, 0x14); + assert.deepEqual(normalized.colors.slice(0, 3), ["#aabbcc", ...GLORIOUS_DEFAULT_LIGHTING.colors.slice(1, 3)]); + assert.deepEqual(normalized.colors.length, 7); + const roundTrip = gloriousNormalizeLighting(defaultLighting()); + assert.deepEqual(roundTrip, defaultLighting()); +}); diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index be69708..b511b51 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -113,7 +113,7 @@ export type MouseLightingMode = | "Breathing dual"; export interface MouseStatus { - brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK"; + brand: "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VGN" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "Glorious"; name: string; /** Driver-supplied UI policy (optional; keeps control.ts brand-agnostic). */ ui?: MouseUiHints; diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index c345651..a3517d5 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -26,9 +26,10 @@ import { WLMouseHidClient } from "./wlmouse/hid.ts"; import { WootingHidClient } from "./wooting/hid.ts"; import { ZaunkoenigHidClient } from "./zaunkoenig/hid.ts"; import { GWolvesHidClient } from "./gwolves/hid.ts"; +import { GloriousHidClient } from "./glorious/hid.ts"; export type PulsarClient = PulsarHidClient | PulsarProHidClient | PulsarXs1HidClient; -export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient; +export type SupportedClient = LogitechHidppClient | PulsarClient | EggOp1HidClient | EggWeHidClient | FinalmouseHidClient | WLMouseHidClient | LamzuHidClient | OrbitalHidClient | RazerHidClient | RazerViperHidClient | RazerViperMiniHidClient | RazerViperV4ProHidClient | RazerCobraHidClient | TeevolutionHidClient | AtkHidClient | VgnF2HidClient | KeychronHidClient | ModdoHidClient | NinjutsoHidClient | ZaunkoenigHidClient | AttackSharkHidClient | FantechHidClient | WootingHidClient | WallhackMouseHidClient | WallhackKeyboardHidClient | GWolvesHidClient | GloriousHidClient; export interface DeviceDriver { brand: string; @@ -66,6 +67,7 @@ export const DEVICE_DRIVERS: readonly DeviceDriver[] = [ { brand: "WALLHACK", supports: (device) => WallhackMouseHidClient.isSupported(device), create: (device) => new WallhackMouseHidClient(device), score: () => 8 }, { brand: "WALLHACK", supports: (device) => WallhackKeyboardHidClient.isSupported(device), create: (device) => new WallhackKeyboardHidClient(device), score: () => 8 }, { brand: "G-Wolves", supports: (device) => GWolvesHidClient.isSupported(device), create: (device) => new GWolvesHidClient(device), score: () => 7 }, + { brand: "Glorious", supports: (device) => GloriousHidClient.isSupported(device), create: (device) => new GloriousHidClient(device), score: () => 5 }, ]; function driverFor(device: HIDDevice): DeviceDriver | undefined { diff --git a/src/drivers/vendors.ts b/src/drivers/vendors.ts index cc1ba3a..4ca5520 100644 --- a/src/drivers/vendors.ts +++ b/src/drivers/vendors.ts @@ -60,8 +60,19 @@ export const VENDOR_ID = { wallhack: WALLHACK_VENDOR_ID, wallhackKeyboardAlt: WALLHACK_KEYBOARD_ALT_VENDOR_ID, gwolves: 0x33e4, + glorious: 0x093a, } as const; +// Pixart-based Model O 2 / I 2 family (OpenRGB issue #4649, linux-hardware.org). +export const GLORIOUS_PRODUCTS: ReadonlyMap = new Map([ + [0x821d, { name: "Model I 2 Wireless", wireless: false }], + [0x822a, { name: "Model O 2 Wireless", wireless: false }], + [0x822b, { name: "Model O 2 Bluetooth", wireless: true }], + [0x822d, { name: "Model O 2 Wireless receiver", wireless: true }], + [0x826a, { name: "Model O 2 Mini Wireless", wireless: false }], + [0x826d, { name: "Model O 2 Mini Wireless receiver", wireless: true }], +]); + // Keychron VIA raw HID. 0x0440 is Nape Pro wired; 0xd026/0xd029 are shared Link-KM receivers. export const KEYCHRON_PRODUCT_IDS = [0x0440, 0xd026, 0xd029] as const; @@ -300,4 +311,5 @@ export const SUPPORTED_HID_FILTERS: HIDDeviceFilter[] = [ ...WALLHACK_HID_FILTERS, { vendorId: VENDOR_ID.gwolves, productId: 0x5618, usagePage: 0xff02 }, { vendorId: VENDOR_ID.gwolves, productId: 0x3854, usagePage: 0xff02 }, + { vendorId: VENDOR_ID.glorious }, ]; diff --git a/src/glorious/index.ts b/src/glorious/index.ts new file mode 100644 index 0000000..dedc2a2 --- /dev/null +++ b/src/glorious/index.ts @@ -0,0 +1,294 @@ +/** + * Pure encoding rules of the Glorious Model O 2 / I 2 family (Pixart firmware, + * VID 0x093a). The configuration interface is write-only: the firmware accepts + * a 256-byte settings payload assembled from four 64-byte feature reports + * (report ID 0x03) on USB interface 1, but never echoes settings back. + * Reverse-engineered layout: https://github.com/zeppybabe/gloriousctl-linux. + */ + +export const GLORIOUS_CONFIG_REPORT_ID = 0x03; +export const GLORIOUS_PACKET_LENGTH = 64; + +const SETTINGS_FRAGMENTS = 4; +const SETTINGS_CMD: readonly [number, number] = [0x04, 0xfb]; + +export const GLORIOUS_DPI_UNIT = 50; +export const GLORIOUS_DPI_MIN = 100; +export const GLORIOUS_DPI_MAX = 26000; + +/** Payload byte [9]: 0x01(1k), 0x02(125), 0x03(250), 0x04(500). */ +export const GLORIOUS_POLLING_RATES: ReadonlyArray = [ + [0x02, 125], + [0x03, 250], + [0x04, 500], + [0x01, 1000], +]; + +/** Payload byte [7]: 0x01 (1 mm) or 0x02 (2 mm); 0.7 mm is not exposed. */ +export const GLORIOUS_LOD_MEDIUM_MM = 1; +export const GLORIOUS_LOD_HIGH_MM = 2; + +/** Payload byte [8]: debounce in milliseconds, even numbers, 0x00-0x10. */ +export const GLORIOUS_DEBOUNCE_MAX_MS = 16; + +export const GLORIOUS_MAX_STAGES = 6; + +/** Lighting payload (cmd 02 fb) RGB effect ids ([5]). */ +export const GLORIOUS_RGB_EFFECTS = { + off: 0x00, + glorious: 0x01, + seamlessBreathing: 0x02, + breathing: 0x03, + normallyOn: 0x04, + breathingSingle: 0x05, + tail: 0x06, + rave: 0x07, + wave: 0x08, +} as const; + +export type GloriousRgbEffectId = keyof typeof GLORIOUS_RGB_EFFECTS; + +export const GLORIOUS_EFFECT_OPTIONS: ReadonlyArray = [ + ["glorious", "Glorious"], + ["off", "LEDs off"], + ["seamlessBreathing", "Seamless breathing"], + ["breathing", "Breathing (rainbow)"], + ["normallyOn", "Solid color"], + ["breathingSingle", "Breathing (solid)"], + ["tail", "Tail"], + ["rave", "Rave"], + ["wave", "Wave"], +]; + +/** Brightness ([6]/[7]) and speed ([9]) map onto five/four CORE levels. */ +export const GLORIOUS_BRIGHTNESS_LEVELS: ReadonlyArray = [0x00, 0x05, 0x0a, 0x0f, 0x14]; +export const GLORIOUS_SPEED_LEVELS: ReadonlyArray = [0x05, 0x0a, 0x0f, 0x14]; + +export interface GloriousLighting { + /** One of GLORIOUS_RGB_EFFECTS values. */ + effect: number; + brightnessWired: number; + brightnessWireless: number; + speed: number; + /** "#rrggbb" strings: [0] primary plus up to six cycle colors. */ + colors: string[]; +} + +export const GLORIOUS_DEFAULT_LIGHTING: GloriousLighting = { + effect: GLORIOUS_RGB_EFFECTS.glorious, + brightnessWired: 0x14, + brightnessWireless: 0x14, + speed: 0x0a, + colors: ["#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#4b0082", "#9400d3"], +}; + +const LIGHTING_CMD: readonly [number, number] = [0x02, 0xfb]; +const LIGHTING_FRAGMENTS = 3; +const LIGHTING_MODIFIER = 0x14; + +/** Number of palette slots an effect consumes ([8]): solid 1, rave 2, rainbow 7. */ +export function gloriousLightingColorCount(effect: number): number { + if (effect === GLORIOUS_RGB_EFFECTS.rave) return 2; + if (effect === GLORIOUS_RGB_EFFECTS.off + || effect === GLORIOUS_RGB_EFFECTS.normallyOn + || effect === GLORIOUS_RGB_EFFECTS.breathingSingle) return 1; + return 7; +} + +function hexToRgb(hex: string): [number, number, number] { + return [ + parseInt(hex.slice(1, 3), 16) || 0, + parseInt(hex.slice(3, 5), 16) || 0, + parseInt(hex.slice(5, 7), 16) || 0, + ]; +} + +/** + * Builds the three 64-byte feature-report fragments of the lighting payload: + * fragment 0 carries effect, wireless/wired brightness, color count, speed, + * modifier, and the primary color; fragment 1 echoes the effect and holds the + * six-cycle palette; fragment 2 echoes the effect again. + */ +export function buildGloriousLightingPayload(lighting: GloriousLighting): Uint8Array[] { + const fragments = Array.from({ length: LIGHTING_FRAGMENTS }, () => new Uint8Array(GLORIOUS_PACKET_LENGTH)); + fragments.forEach((fragment, index) => { + fragment[0] = GLORIOUS_CONFIG_REPORT_ID; + fragment[1] = LIGHTING_CMD[0]; + fragment[2] = LIGHTING_CMD[1]; + fragment[3] = index; + fragment[4] = 0x01; + }); + + const first = fragments[0]; + first[5] = lighting.effect; + first[6] = lighting.brightnessWireless; + first[7] = lighting.brightnessWired; + first[8] = gloriousLightingColorCount(lighting.effect); + first[9] = lighting.speed; + first[10] = LIGHTING_MODIFIER; + first.set(hexToRgb(lighting.colors[0] ?? GLORIOUS_DEFAULT_LIGHTING.colors[0]), 11); + + const second = fragments[1]; + second[5] = lighting.effect; + for (let index = 1; index <= 6; index += 1) { + second.set(hexToRgb(lighting.colors[index] ?? "#000000"), 3 + index * 3); + } + + fragments[2][5] = lighting.effect; + return fragments; +} + +/** Repairs untrusted persisted lighting state into a payload-safe object. */ +export function gloriousNormalizeLighting(value: unknown): GloriousLighting { + const parsed = typeof value === "object" && value !== null ? value as Partial : {}; + const validEffects = new Set(Object.values(GLORIOUS_RGB_EFFECTS)); + const nearestLevel = (levels: ReadonlyArray, candidate: unknown): number => { + if (typeof candidate !== "number") return levels.at(-1)!; + return levels.reduce((best, level) => + Math.abs(level - candidate) < Math.abs(best - candidate) ? level : best); + }; + const colorPattern = /^#[0-9a-f]{6}$/i; + return { + effect: validEffects.has(Number(parsed.effect) as never) ? Number(parsed.effect) : GLORIOUS_DEFAULT_LIGHTING.effect, + brightnessWired: nearestLevel(GLORIOUS_BRIGHTNESS_LEVELS, parsed.brightnessWired), + brightnessWireless: nearestLevel(GLORIOUS_BRIGHTNESS_LEVELS, parsed.brightnessWireless), + speed: nearestLevel(GLORIOUS_SPEED_LEVELS, parsed.speed), + colors: GLORIOUS_DEFAULT_LIGHTING.colors.map((fallback, index) => { + const stored = Array.isArray(parsed.colors) ? parsed.colors[index] : undefined; + return typeof stored === "string" && colorPattern.test(stored) ? stored.toLowerCase() : fallback; + }), + }; +} + +export interface GloriousSettings { + /** Zero-indexed active DPI stage ([5]). */ + activeStage: number; + /** Enabled stages, 4-6 ([6]). */ + stageCount: number; + /** Six DPI stage values; unused stages stay at 0. */ + stageDpis: number[]; + /** "#rrggbb" LED color shown while the DPI button cycles through a stage. */ + stageColors: string[]; + lodMm: number; + debounceMs: number; + pollingCode: number; +} + +/** Factory per-stage indicator colors (red, blue, green, yellow), then unset. */ +export const GLORIOUS_DEFAULT_STAGE_COLORS: ReadonlyArray = [ + "#ff0000", + "#0000ff", + "#00ff00", + "#ffff00", + "#000000", + "#000000", +]; + +export const GLORIOUS_DEFAULT_SETTINGS: GloriousSettings = { + activeStage: 0, + stageCount: 4, + stageDpis: [400, 800, 1600, 3200, 0, 0], + stageColors: [...GLORIOUS_DEFAULT_STAGE_COLORS], + lodMm: GLORIOUS_LOD_MEDIUM_MM, + debounceMs: 10, + pollingCode: 0x01, +}; + +// Stage 1 lives in fragment 0 after the global bytes; stages 2-3 share +// fragment 1, stages 4-5 fragment 2, and stage 6 fragment 3. +const STAGE_OFFSETS: ReadonlyArray = [ + [0, 11], + [1, 5], + [1, 10], + [2, 5], + [2, 10], + [3, 5], +]; + +export function gloriousEncodeDpi(dpi: number): number { + return Math.round(dpi / GLORIOUS_DPI_UNIT); +} + +export function gloriousIsSupportedDpi(dpi: number): boolean { + return Number.isInteger(dpi) + && dpi >= GLORIOUS_DPI_MIN + && dpi <= GLORIOUS_DPI_MAX + && dpi % GLORIOUS_DPI_UNIT === 0; +} + +export function gloriousEncodePolling(hertz: number): number | null { + return GLORIOUS_POLLING_RATES.find(([, value]) => value === hertz)?.[0] ?? null; +} + +export function gloriousDecodePolling(code: number): number | null { + return GLORIOUS_POLLING_RATES.find(([value]) => value === code)?.[1] ?? null; +} + +export function gloriousSanitizeDebounce(milliseconds: number): number { + const clamped = Math.min(Math.max(Math.round(milliseconds), 0), GLORIOUS_DEBOUNCE_MAX_MS); + return clamped % 2 === 0 ? clamped : clamped - 1; +} + +/** + * Builds the four 64-byte feature-report fragments of the settings payload: + * header (report ID 0x03, cmd 04 fb, sequence, pad 0x01), then the global + * bytes ([5] active stage, [6] total stages, [7] LOD, [8] debounce, [9] + * polling) followed by little-endian DPI u16 divided by 50 plus RGB per stage. + */ +export function buildGloriousSettingsPayload(settings: GloriousSettings): Uint8Array[] { + const fragments = Array.from({ length: SETTINGS_FRAGMENTS }, () => new Uint8Array(GLORIOUS_PACKET_LENGTH)); + fragments.forEach((fragment, index) => { + fragment[0] = GLORIOUS_CONFIG_REPORT_ID; + fragment[1] = SETTINGS_CMD[0]; + fragment[2] = SETTINGS_CMD[1]; + fragment[3] = index; + fragment[4] = 0x01; + }); + + const first = fragments[0]; + first[5] = settings.activeStage; + first[6] = settings.stageCount; + first[7] = settings.lodMm; + first[8] = settings.debounceMs; + first[9] = settings.pollingCode; + first[10] = 0x00; + + STAGE_OFFSETS.forEach(([fragmentIndex, offset], stageIndex) => { + const encoded = gloriousEncodeDpi(settings.stageDpis[stageIndex] ?? 0); + const fragment = fragments[fragmentIndex]; + fragment[offset] = encoded & 0xff; + fragment[offset + 1] = encoded >> 8 & 0xff; + fragment.set(hexToRgb(settings.stageColors?.[stageIndex] ?? GLORIOUS_DEFAULT_STAGE_COLORS[stageIndex]), offset + 2); + }); + return fragments; +} + +/** Repairs untrusted persisted state into a payload-safe settings object. */ +export function gloriousNormalizeSettings(value: unknown): GloriousSettings { + const parsed = typeof value === "object" && value !== null ? value as Partial : {}; + const stageDpis = GLORIOUS_DEFAULT_SETTINGS.stageDpis.map((fallback, index) => { + const stored = Array.isArray(parsed.stageDpis) ? parsed.stageDpis[index] : undefined; + return typeof stored === "number" && gloriousIsSupportedDpi(stored) ? stored : fallback; + }); + const pollingCode = Number(parsed.pollingCode); + const colorPattern = /^#[0-9a-f]{6}$/i; + return { + activeStage: sanitizeIndex(parsed.activeStage), + stageCount: Math.min( + Math.max(sanitizeIndex(parsed.stageCount, GLORIOUS_MAX_STAGES), GLORIOUS_DEFAULT_SETTINGS.stageCount), + GLORIOUS_MAX_STAGES), + stageDpis, + stageColors: GLORIOUS_DEFAULT_STAGE_COLORS.map((fallback, index) => { + const stored = Array.isArray(parsed.stageColors) ? parsed.stageColors[index] : undefined; + return typeof stored === "string" && colorPattern.test(stored) ? stored.toLowerCase() : fallback; + }), + lodMm: parsed.lodMm === GLORIOUS_LOD_HIGH_MM ? GLORIOUS_LOD_HIGH_MM : GLORIOUS_LOD_MEDIUM_MM, + debounceMs: gloriousSanitizeDebounce( + typeof parsed.debounceMs === "number" ? parsed.debounceMs : GLORIOUS_DEFAULT_SETTINGS.debounceMs), + pollingCode: gloriousDecodePolling(pollingCode) ? pollingCode : GLORIOUS_DEFAULT_SETTINGS.pollingCode, + }; +} + +function sanitizeIndex(value: unknown, max = GLORIOUS_MAX_STAGES - 1): number { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= max ? value : 0; +} diff --git a/src/index.ts b/src/index.ts index d325b8b..1e90207 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,3 +17,4 @@ export * as vgn from "./vgn/index.js"; export * as wlmouse from "./wlmouse/index.js"; export * as zaunkoenig from "./zaunkoenig/index.js"; export * as gwolves from "./gwolves/index.js"; +export * as glorious from "./glorious/index.js";