diff --git a/src/drivers/gwolves/hid.ts b/src/drivers/gwolves/hid.ts index 451774b0..e982ca25 100644 --- a/src/drivers/gwolves/hid.ts +++ b/src/drivers/gwolves/hid.ts @@ -1,13 +1,27 @@ -import type { MouseStatus } from "../mouse-types.ts"; +import type { MagneticButtonsStatus, MagneticCalibrationProgress, MouseStatus } from "../mouse-types.ts"; import { GWOLVES_ADDRESS, GWOLVES_COMMAND, + GWOLVES_HTS_PLUS_PRO_MODEL_ID, + GWOLVES_NOTIFICATION, + GWOLVES_RAPID_TRIGGER_MAX, GWOLVES_REPORT_ID, + GWOLVES_TRIGGER_POINT_MAX, + gwolvesAdvanceCalibration, + gwolvesBuildCalibrationPayload, + gwolvesBuildModelPayload, gwolvesBuildReadPayload, gwolvesBuildSimplePayload, gwolvesBuildWritePayload, gwolvesBuildWriteScalarPayload, gwolvesDecodeProfile, + gwolvesDecodeRapidTrigger, + gwolvesDecodeTriggerPoint, + gwolvesEncodeRapidTrigger, + gwolvesEncodeTriggerPoint, + gwolvesParseButtonDepth, + gwolvesParseModelId, + type GWolvesCalibrationStage, gwolvesEncodeDpi, gwolvesEncodePollingRate, gwolvesParseBattery, @@ -48,6 +62,11 @@ const RESPONSE_TIMEOUT_MS = 700; // the key table's sixth, DPI, slot stays hidden. const BUTTON_LIMITS = { buttons: 5, actions: TEEVOLUTION_SHARED_BUTTON_OPTIONS }; const SUPPORTED_POLLING_RATES = [125, 250, 500, 1000, 2000, 4000, 8000]; +const CALIBRATION_TIMING = { settleMs: 2_000, noReadingsMs: 8_000, idleMs: 30_000, maxMs: 90_000 }; +const MAGNETIC_ADDRESS = { + trigger: [GWOLVES_ADDRESS.leftTrigger, GWOLVES_ADDRESS.rightTrigger], + rapidTrigger: [GWOLVES_ADDRESS.leftRapidTrigger, GWOLVES_ADDRESS.rightRapidTrigger], +} as const; export class GWolvesHidClient { readonly device: HIDDevice; @@ -58,11 +77,21 @@ export class GWolvesHidClient { timer: number; } | null = null; + /** Exposed so tests do not have to wait out the real delays. */ + calibrationTiming = { ...CALIBRATION_TIMING }; + private receiverModelId: number | null | undefined; + private readonly depthListeners = new Set<(left: number, right: number) => void>(); + private readonly onInputReport = (event: HIDInputReportEvent): void => { if (event.reportId !== GWOLVES_REPORT_ID) return; const response = new Uint8Array( event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength), ); + if (response[0] === GWOLVES_NOTIFICATION) { + const depth = gwolvesParseButtonDepth(response); + if (depth) for (const listener of this.depthListeners) listener(depth.left, depth.right); + return; + } const waiter = this.waiter; if (!waiter || response[0] !== waiter.command) return; window.clearTimeout(waiter.timer); @@ -117,7 +146,9 @@ export class GWolvesHidClient { async readStatus(): Promise { await this.open(); - const { model, wireless } = this.product; + const { wireless } = this.product; + const magnetic = await this.isMagnetic(); + const model = magnetic && wireless ? "HTS Plus Pro" : this.product.model; const batteryResponse = await this.transact(gwolvesBuildSimplePayload(GWOLVES_COMMAND.battery)); const firmwareResponse = await this.transact(gwolvesBuildSimplePayload(GWOLVES_COMMAND.firmware)); const profile = await this.readProfile(); @@ -148,6 +179,7 @@ export class GWolvesHidClient { rippleControl: settings.rippleControl, performanceMode: settings.performanceMode, liftOffDistance: settings.liftOffDistance, + ...(magnetic ? { magneticButtons: await this.readMagnetic().catch(() => undefined) } : {}), ...(keys ? { buttonMappings: teevolutionDecodeButtonMappings(keys, 0, BUTTON_LIMITS.buttons), buttonOptions: BUTTON_LIMITS.actions } : {}), firmware: firmware ? [`Mouse ${firmware}`] : [], ui: { @@ -204,7 +236,123 @@ export class GWolvesHidClient { ); } + /** True for the magnetic model. On the shared receiver the mouse behind it is asked once. */ + async isMagnetic(): Promise { + if (!this.product.wireless) return this.product.magnetic === true; + if (this.receiverModelId === undefined) { + this.receiverModelId = await this.transact(gwolvesBuildModelPayload(Array.from({ length: 4 }, () => Math.floor(Math.random() * 256)))) + .then((response) => gwolvesParseModelId(response)?.modelId ?? null) + .catch(() => null); + } + return this.receiverModelId === GWOLVES_HTS_PLUS_PRO_MODEL_ID; + } + + async readMagnetic(): Promise { + const wired = !this.product.wireless; + const buttons: MagneticButtonsStatus["buttons"] = []; + for (const side of [0, 1] as const) { + const triggerPoint = gwolvesDecodeTriggerPoint(await this.read(MAGNETIC_ADDRESS.trigger[side], 2)); + const rapid = gwolvesDecodeRapidTrigger(await this.read(MAGNETIC_ADDRESS.rapidTrigger[side], 2)); + buttons.push({ + switchType: "magnetic", + triggerPoint, + rapidTrigger: rapid && rapid.level >= 1 && rapid.level <= GWOLVES_RAPID_TRIGGER_MAX ? rapid.level : null, + rapidTriggerEnabled: rapid ? rapid.enabled : null, + }); + } + return { + buttons, + triggerPointRange: { min: 1, max: GWOLVES_TRIGGER_POINT_MAX }, + // The web driver offers rapid trigger and calibration on a cable only. + rapidTriggerRange: wired ? { min: 1, max: GWOLVES_RAPID_TRIGGER_MAX } : null, + rapidTriggerUnit: "level", + rapidTriggerSwitch: true, + canChooseSwitchType: false, + calibration: wired ? "unknown" : null, + liveDepth: true, + }; + } + + async setMagneticTriggerPoint(button: 0 | 1, point: number): Promise { + await this.writeScalar(MAGNETIC_ADDRESS.trigger[button], gwolvesEncodeTriggerPoint(point)); + const confirmed = gwolvesDecodeTriggerPoint(await this.read(MAGNETIC_ADDRESS.trigger[button], 2)); + if (confirmed !== point) throw new Error(`The ${this.product.model} kept trigger point ${confirmed ?? "unknown"} instead of ${point}.`); + return confirmed; + } + + async setMagneticRapidTrigger(button: 0 | 1, enabled: boolean, level: number): Promise<{ enabled: boolean; level: number }> { + if (this.product.wireless) throw new Error("Rapid trigger can only be changed with the mouse on its cable."); + await this.writeScalar(MAGNETIC_ADDRESS.rapidTrigger[button], gwolvesEncodeRapidTrigger(enabled, level)); + const confirmed = gwolvesDecodeRapidTrigger(await this.read(MAGNETIC_ADDRESS.rapidTrigger[button], 2)); + if (!confirmed || confirmed.enabled !== enabled || confirmed.level !== level) { + throw new Error(`The ${this.product.model} did not keep the rapid trigger setting.`); + } + return confirmed; + } + + /** Live press depth of the left and right button, 0 to 100, while the mouse streams it. */ + onButtonDepth(listener: (left: number, right: number) => void): () => void { + this.depthListeners.add(listener); + return () => { this.depthListeners.delete(listener); }; + } + + /** + * Starts the calibration and follows it by press depth. The mouse does not + * report a result, so success means both buttons were seen fully pressed and + * then fully released. Wired only, like G-Wolves' web driver. + */ + async calibrateMagneticButtons(onProgress: (progress: MagneticCalibrationProgress) => void, signal?: AbortSignal): Promise { + if (this.product.wireless) throw new Error("Calibrate the magnetic switches with the mouse on its cable."); + let stage = "pressBoth" as GWolvesCalibrationStage; + let settled = false; + let seen = false; + let last = Date.now(); + let depth = { left: 0, right: 0 }; + const report = (): void => onProgress({ + ...depth, + step: stage === "pressBoth" ? 2 : 3, + steps: 3, + message: stage === "pressBoth" + ? "Press and hold both the left and right buttons fully for 2 seconds." + : stage === "releaseBoth" ? "Release both the left and right buttons." : "Calibration Successful", + }); + const stop = this.onButtonDepth((left, right) => { + seen = true; + last = Date.now(); + depth = { left, right }; + if (!settled) return; + stage = gwolvesAdvanceCalibration(stage, depth); + report(); + }); + try { + onProgress({ ...depth, step: 1, steps: 3, message: "Do not press any buttons until the white LED stops blinking and turns solid green." }); + const response = await this.transact(gwolvesBuildCalibrationPayload()); + if (!gwolvesReportChecksumIsValid(response)) throw new Error("The mouse did not answer the calibration request."); + await new Promise((resolve) => window.setTimeout(resolve, this.calibrationTiming.settleMs)); + settled = true; + seen = false; + const started = Date.now(); + last = started; + report(); + while (stage !== "done") { + await new Promise((resolve) => window.setTimeout(resolve, 50)); + if (signal?.aborted) throw new Error("Calibration cancelled."); + const now = Date.now(); + if (!seen && now - started > this.calibrationTiming.noReadingsMs) { + throw new Error("No live button readings arrived from the mouse, so the calibration could not be followed."); + } + if (now - started > this.calibrationTiming.maxMs || (seen && now - last > this.calibrationTiming.idleMs)) { + throw new Error("Calibration did not finish. Fully press and release both buttons as prompted, then try again."); + } + } + report(); + } finally { + stop(); + } + } + async close(): Promise { + this.depthListeners.clear(); this.failWaiter(new Error("The G-Wolves device was closed.")); this.device.removeEventListener("inputreport", this.onInputReport); if (this.device.opened) await this.device.close(); diff --git a/src/drivers/gwolves/magnetic.test.ts b/src/drivers/gwolves/magnetic.test.ts new file mode 100644 index 00000000..7deeeb9f --- /dev/null +++ b/src/drivers/gwolves/magnetic.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { GWolvesHidClient } from "./hid.ts"; +import { GWOLVES_COMMAND, gwolvesReportChecksum } from "@openmouse/protocol/gwolves"; + +if (typeof (globalThis as { window?: unknown }).window === "undefined") { + Object.defineProperty(globalThis, "window", { value: globalThis, configurable: true }); +} + +interface FakeOptions { + productId?: number; + modelId?: number | null; + badTrigger?: boolean; + noDepth?: boolean; +} + +function magneticDevice(options: FakeOptions = {}): HIDDevice & { flash: Uint8Array; writes: number[]; calibrations: number; notify: (left: number, right: number) => void } { + const flash = new Uint8Array(256); + const pair = (address: number, value: number): void => { flash[address] = value; flash[address + 1] = (0x55 - value) & 0xff; }; + pair(0xef, 4); + pair(0xf5, 4); + pair(0xf1, 0x80 | 3); + pair(0xf7, 3); + if (options.badTrigger) { flash[0xef] = 7; flash[0xf0] = 7; } + let listener: ((event: HIDInputReportEvent) => void) | null = null; + const emit = (bytes: Uint8Array): void => listener?.({ reportId: 8, data: new DataView(bytes.buffer) } as HIDInputReportEvent); + const notify = (left: number, right: number): void => { + const report = new Uint8Array(16); + report[0] = 10; report[13] = left; report[14] = right; + emit(report); + }; + const state = { writes: [] as number[], calibrations: 0 }; + const fake = { + vendorId: 0x33e4, + productId: options.productId ?? 0x5219, + productName: "G-Wolves", + opened: true, + flash, + notify, + get writes() { return state.writes; }, + get calibrations() { return state.calibrations; }, + collections: [{ + usagePage: 0xff02, usage: 2, children: [], featureReports: [], + inputReports: [{ reportId: 8, items: [{ reportCount: 16, reportSize: 8 }] }], + outputReports: [{ reportId: 8, items: [{ reportCount: 16, reportSize: 8 }] }], + }], + addEventListener: (_: string, fn: (event: HIDInputReportEvent) => void) => { listener = fn; }, + removeEventListener: () => { listener = null; }, + async sendReport(_: number, data: BufferSource) { + const packet = new Uint8Array(data as ArrayBuffer); + const reply = new Uint8Array(16); + reply.set(packet.subarray(0, 5)); + const address = (packet[2]! << 8) | packet[3]!; + if (packet[0] === GWOLVES_COMMAND.read) reply.set(flash.subarray(address, address + packet[4]!), 5); + if (packet[0] === GWOLVES_COMMAND.write) { flash.set(packet.subarray(5, 5 + packet[4]!), address); state.writes.push(address); } + if (packet[0] === GWOLVES_COMMAND.handshake) { + if (options.modelId === null) return; + reply[10] = options.modelId ?? 11; + } + if (packet[0] === GWOLVES_COMMAND.calibrate) { + state.calibrations += 1; + if (!options.noDepth) { + setTimeout(() => notify(40, 20), 5); + setTimeout(() => notify(100, 100), 15); + setTimeout(() => notify(0, 0), 25); + } + } + reply[15] = gwolvesReportChecksum(reply.subarray(0, 15)); + queueMicrotask(() => emit(reply)); + }, + }; + return fake as unknown as HIDDevice & { flash: Uint8Array; writes: number[]; calibrations: number; notify: (left: number, right: number) => void }; +} + +test("only the magnetic catalog entry reads magnetic settings over the cable", async () => { + const pro = await new GWolvesHidClient(magneticDevice()).readStatus(); + assert.equal(pro.name, "G-Wolves HTS Plus Pro"); + assert.deepEqual(pro.magneticButtons?.buttons.map((button) => button.triggerPoint), [5, 5]); + assert.deepEqual(pro.magneticButtons?.buttons.map((button) => [button.rapidTriggerEnabled, button.rapidTrigger]), [[true, 3], [false, 3]]); + assert.deepEqual(pro.magneticButtons?.rapidTriggerRange, { min: 1, max: 10 }); + assert.equal(pro.magneticButtons?.calibration, "unknown"); + + const htxUltra = await new GWolvesHidClient(magneticDevice({ productId: 0x5618 })).readStatus(); + assert.equal(htxUltra.magneticButtons, undefined); +}); + +test("the receiver asks which mouse is behind it, and offers trigger point only", async () => { + const pro = await new GWolvesHidClient(magneticDevice({ productId: 0x3854, modelId: 11 })).readStatus(); + assert.equal(pro.name, "G-Wolves HTS Plus Pro"); + assert.equal(pro.magneticButtons?.rapidTriggerRange, null); + assert.equal(pro.magneticButtons?.calibration, null); + + const other = await new GWolvesHidClient(magneticDevice({ productId: 0x3854, modelId: 5 })).readStatus(); + assert.equal(other.magneticButtons, undefined); + + const silent = await new GWolvesHidClient(magneticDevice({ productId: 0x3854, modelId: null })).readStatus(); + assert.equal(silent.magneticButtons, undefined); +}); + +test("a trigger point that fails its pair check is unknown rather than the most sensitive setting", async () => { + const status = await new GWolvesHidClient(magneticDevice({ badTrigger: true })).readStatus(); + assert.equal(status.magneticButtons?.buttons[0]?.triggerPoint, null); + assert.equal(status.magneticButtons?.buttons[1]?.triggerPoint, 5); +}); + +test("trigger point and rapid trigger write the stored value with its complement", async () => { + const fake = magneticDevice(); + const client = new GWolvesHidClient(fake); + assert.equal(await client.setMagneticTriggerPoint(0, 12), 12); + assert.deepEqual([...fake.flash.subarray(0xef, 0xf1)], [11, 0x55 - 11]); + assert.deepEqual(await client.setMagneticRapidTrigger(1, true, 7), { enabled: true, level: 7 }); + assert.deepEqual([...fake.flash.subarray(0xf7, 0xf9)], [0x80 | 7, (0x55 - (0x80 | 7)) & 0xff]); + await assert.rejects(() => client.setMagneticTriggerPoint(0, 21), /1 to 20/); + await assert.rejects(() => client.setMagneticRapidTrigger(0, true, 11), /1 to 10/); +}); + +test("rapid trigger is refused on the receiver", async () => { + const client = new GWolvesHidClient(magneticDevice({ productId: 0x3854 })); + await assert.rejects(() => client.setMagneticRapidTrigger(0, true, 3), /cable/); + await assert.rejects(() => client.calibrateMagneticButtons(() => {}), /cable/); +}); + +test("calibration finishes after both buttons are pressed fully and released", async () => { + const fake = magneticDevice(); + const client = new GWolvesHidClient(fake); + client.calibrationTiming = { settleMs: 1, noReadingsMs: 500, idleMs: 500, maxMs: 2_000 }; + const steps: number[] = []; + await client.calibrateMagneticButtons((progress) => steps.push(progress.step)); + assert.equal(fake.calibrations, 1); + assert.equal(steps[0], 1); + assert.equal(steps.at(-1), 3); +}); + +test("calibration fails when the mouse sends no press readings", async () => { + const client = new GWolvesHidClient(magneticDevice({ noDepth: true })); + client.calibrationTiming = { settleMs: 1, noReadingsMs: 100, idleMs: 100, maxMs: 1_000 }; + await assert.rejects(() => client.calibrateMagneticButtons(() => {}), /No live button readings/); +}); + +test("live depth reaches listeners and stops after unsubscribing", async () => { + const fake = magneticDevice(); + const client = new GWolvesHidClient(fake); + await client.open(); + const seen: Array<[number, number]> = []; + const stop = client.onButtonDepth((left, right) => seen.push([left, right])); + fake.notify(40, 0x80 | 12); + stop(); + fake.notify(10, 10); + assert.deepEqual(seen, [[40, 12]]); +}); diff --git a/src/drivers/gwolves/products.ts b/src/drivers/gwolves/products.ts index 32d03db2..c3807674 100644 --- a/src/drivers/gwolves/products.ts +++ b/src/drivers/gwolves/products.ts @@ -23,6 +23,8 @@ export interface GWolvesProduct { * no driver here implements yet. */ protocol: "vgn" | "xvi" | "xvi-new"; + /** Left and right buttons are magnetic switches with trigger point and rapid trigger (HTS Plus Pro). */ + magnetic?: boolean; } export const GWOLVES_VENDOR_ID = 0x33e4; @@ -30,6 +32,8 @@ export const GWOLVES_VENDOR_ID = 0x33e4; export const GWOLVES_PRODUCTS: ReadonlyMap = new Map([ [0x5618, { model: "HTX Ultra", wireless: false, verified: true, protocol: "vgn" }], [0x3854, { model: "HTX Ultra", wireless: true, verified: true, protocol: "vgn" }], + // Read from mouse.fit (env-models.json, XVI 0, ButtonType 1, MID 11). Not tried on hardware. + [0x5219, { model: "HTS Plus Pro", wireless: false, verified: false, protocol: "vgn", magnetic: true }], // Everything below is transcribed from G-Wolves' own live web-driver config // (https://mouse.xyz/Config/env-models.json), not independently // hardware-tested. Re-checked 2026-09-26: only the HTX Ultra generation diff --git a/src/drivers/lamzu/hid.ts b/src/drivers/lamzu/hid.ts index 25638fbd..33539d08 100644 --- a/src/drivers/lamzu/hid.ts +++ b/src/drivers/lamzu/hid.ts @@ -1,4 +1,4 @@ -import type { MouseStatus } from "../mouse-types.ts"; +import type { MagneticButtonsStatus, MagneticCalibrationProgress, MouseStatus } from "../mouse-types.ts"; import { COMPX_HEADER_LENGTH as HEADER_LENGTH, COMPX_PACKET_LENGTH as PACKET_LENGTH, @@ -23,6 +23,22 @@ import { type LunaFuryLightningMode, type LunaFurySettings, type LunaFuryWheelGuard, + MAGNETIC_CALIBRATION_ERRORS, + MAGNETIC_CALIBRATION_STATE, + MAGNETIC_CALIBRATION_STEPS, + MAGNETIC_RAPID_TRIGGER_MAX_MS, + MAGNETIC_RELEASE_FOLLOWS, + MAGNETIC_SWITCH, + MAGNETIC_TRAVEL_MAX, + magneticCalibrationPrompt, + magneticDecodeCalibration, + magneticDecodeEid, + magneticDecodeFault, + magneticDecodeRapidTrigger, + magneticDecodeSwitchTypes, + magneticDecodeTravel, + magneticRead, + magneticWrite, type CompaxDpiStage, type LamzuProduct, } from "@openmouse/protocol/lamzu"; @@ -36,6 +52,7 @@ const QUICK_ATTEMPTS = 3; const SLEEP_DISABLED_MIN = 0xff00; const SLEEP_MAX_SECONDS = 0xfeff; const DPI_STEP = 50; +const CALIBRATION_MAX_MS = 240_000; const DPI_MAX = 30000; const DEBOUNCE_MAX_MS = 15; const NOTIFY_REPORT_ID = 4; @@ -69,6 +86,8 @@ interface LamzuRequest { args: readonly number[]; attempts?: number; matchesReply?: (payload: Uint8Array) => boolean; + /** Return the whole 58 byte payload instead of the length the mouse echoes. */ + full?: boolean; } const READ = { @@ -284,7 +303,9 @@ export class LamzuHidClient { ? await this.readLunaFurySettings(profile) : undefined; const stage = stages[activeStage]; if (!stage) throw new Error("The mouse did not report any DPI stages."); + const magneticButtons = this.profile()?.magnetic ? await this.readMagnetic(profile).catch(() => undefined) : undefined; return this.lastStatus = { + ...(magneticButtons ? { magneticButtons } : {}), brand: this.deviceBrand(), name: this.displayName(), ui: { @@ -534,6 +555,128 @@ export class LamzuHidClient { return confirmed.x; } + async readMagnetic(profile: number = this.activeProfile): Promise { + const fault = await this.request(magneticRead.fault).then(magneticDecodeFault).catch(() => null); + const types = !fault || fault.calibrated + ? await this.request(magneticRead.switchTypes).then(magneticDecodeSwitchTypes).catch(() => null) + : null; + const buttons: MagneticButtonsStatus["buttons"] = []; + for (const side of [0, 1] as const) { + const key = side + 1; + const travel = await this.request(magneticRead.travel(profile, key)).then(magneticDecodeTravel).catch(() => null); + const rapid = await this.request(magneticRead.rapidTrigger(profile, key)).then(magneticDecodeRapidTrigger).catch(() => null); + const type = fault && !fault.calibrated ? MAGNETIC_SWITCH.optical : types?.[side]; + buttons.push({ + switchType: type === MAGNETIC_SWITCH.magnetic ? "magnetic" : type === MAGNETIC_SWITCH.optical ? "optical" : null, + triggerPoint: travel && travel.press >= 1 && travel.press <= MAGNETIC_TRAVEL_MAX ? travel.press : null, + releasePoint: travel ? (travel.release === MAGNETIC_RELEASE_FOLLOWS ? null : travel.release) : null, + rapidTrigger: rapid ? rapid : null, + rapidTriggerEnabled: rapid === null ? null : rapid > 0, + }); + } + return { + buttons, + triggerPointRange: { min: 1, max: MAGNETIC_TRAVEL_MAX }, + releasePointRange: { min: 1, max: MAGNETIC_TRAVEL_MAX }, + rapidTriggerRange: { min: 1, max: MAGNETIC_RAPID_TRIGGER_MAX_MS }, + rapidTriggerUnit: "ms", + rapidTriggerSwitch: false, + canChooseSwitchType: true, + calibration: !fault ? "unknown" : fault.calibrated && !fault.needsRecalibration ? "calibrated" : "needed", + liveDepth: false, + }; + } + + /** Press travel for one button, keeping its release travel. */ + async setMagneticTriggerPoint(button: 0 | 1, point: number): Promise { + if (!Number.isInteger(point) || point < 1 || point > MAGNETIC_TRAVEL_MAX) { + throw new RangeError(`Trigger point must be 1 to ${MAGNETIC_TRAVEL_MAX}.`); + } + const profile = await this.currentProfile(); + const current = magneticDecodeTravel(await this.request(magneticRead.travel(profile, button + 1))); + await this.request(magneticWrite.travel(profile, button + 1, point, current.release)); + const confirmed = magneticDecodeTravel(await this.request(magneticRead.travel(profile, button + 1))); + if (confirmed.press !== point) throw new Error(`The mouse kept trigger point ${confirmed.press} instead of ${point}.`); + return confirmed.press; + } + + /** Release travel for one button; null makes the release follow the trigger point. */ + async setMagneticReleasePoint(button: 0 | 1, point: number | null): Promise { + if (point !== null && (!Number.isInteger(point) || point < 1 || point > MAGNETIC_TRAVEL_MAX || point === MAGNETIC_RELEASE_FOLLOWS)) { + throw new RangeError(`Release point must be 1 to ${MAGNETIC_TRAVEL_MAX}, other than ${MAGNETIC_RELEASE_FOLLOWS}.`); + } + const profile = await this.currentProfile(); + const current = magneticDecodeTravel(await this.request(magneticRead.travel(profile, button + 1))); + const wanted = point ?? MAGNETIC_RELEASE_FOLLOWS; + await this.request(magneticWrite.travel(profile, button + 1, current.press, wanted)); + const confirmed = magneticDecodeTravel(await this.request(magneticRead.travel(profile, button + 1))); + if (confirmed.release !== wanted) throw new Error(`The mouse kept release travel ${confirmed.release} instead of ${wanted}.`); + return point; + } + + /** Rapid trigger in milliseconds; off is stored as 0. */ + async setMagneticRapidTrigger(button: 0 | 1, enabled: boolean, milliseconds: number): Promise<{ enabled: boolean; level: number }> { + if (!Number.isInteger(milliseconds) || milliseconds < 1 || milliseconds > MAGNETIC_RAPID_TRIGGER_MAX_MS) { + throw new RangeError(`Rapid trigger must be 1 to ${MAGNETIC_RAPID_TRIGGER_MAX_MS} ms.`); + } + const profile = await this.currentProfile(); + const wanted = enabled ? milliseconds : 0; + await this.request(magneticWrite.rapidTrigger(profile, button + 1, wanted)); + const confirmed = magneticDecodeRapidTrigger(await this.request(magneticRead.rapidTrigger(profile, button + 1))); + if (confirmed !== wanted) throw new Error(`The mouse kept rapid trigger at ${confirmed} ms instead of ${wanted} ms.`); + return { enabled, level: milliseconds }; + } + + /** Chooses magnetic or optical for each button. Magnetic needs a calibrated mouse. */ + async setMagneticSwitchTypes(types: readonly ["magnetic" | "optical", "magnetic" | "optical"]): Promise { + const fault = magneticDecodeFault(await this.request(magneticRead.fault)); + if (!fault.calibrated && types.includes("magnetic")) throw new Error("Calibrate the magnetic switches before choosing Magnetic."); + const eid = magneticDecodeEid(await this.request(magneticRead.eid)); + const wanted = types.map((type) => MAGNETIC_SWITCH[type]) as [number, number]; + await this.request(magneticWrite.switchTypes(eid, wanted[0], wanted[1])); + const confirmed = magneticDecodeSwitchTypes(await this.request(magneticRead.switchTypes)); + if (confirmed[0] !== wanted[0] || confirmed[1] !== wanted[1]) throw new Error("The mouse kept its previous switch mode."); + } + + /** Runs the manual calibration. Resolves on success, rejects with the mouse's own reason on failure. */ + async calibrateMagneticButtons(onProgress: (progress: MagneticCalibrationProgress) => void, signal?: AbortSignal): Promise { + const poll = (state: number): Promise => this.request({ ...magneticWrite.calibration(state), full: true }); + const wait = (milliseconds: number): Promise => this.delay(milliseconds); + const first = magneticDecodeCalibration(await poll(5)); + const two = first.twoButtons; + await poll(two ? 4 : 1); + const started = Date.now(); + let silent = 0; + for (;;) { + if (signal?.aborted) throw new Error("Calibration cancelled."); + if (Date.now() - started > CALIBRATION_MAX_MS) throw new Error("Calibration timed out. Fully press and release as prompted, then recalibrate."); + await wait(100); + let reading; + try { + reading = magneticDecodeCalibration(await poll(two ? 5 : 3)); + } catch (error) { + if (++silent > 25) throw new Error("The mouse stopped answering during calibration.", { cause: error }); + continue; + } + silent = 0; + const message = two + ? magneticCalibrationPrompt(reading.mask) ?? MAGNETIC_CALIBRATION_STEPS[reading.state] ?? "Calibrating..." + : MAGNETIC_CALIBRATION_STEPS[reading.state] ?? "Calibrating..."; + onProgress({ + left: two ? Math.min(reading.left, 100) : Math.min(reading.progress, 100), + right: two ? Math.min(reading.right, 100) : Math.min(reading.progress, 100), + message, + step: Math.min(Math.max(reading.state, 1), 6), + steps: 6, + }); + if (reading.state === MAGNETIC_CALIBRATION_STATE.success) return; + if (reading.state === MAGNETIC_CALIBRATION_STATE.cancelled) throw new Error(MAGNETIC_CALIBRATION_STEPS[9]); + if (reading.state === MAGNETIC_CALIBRATION_STATE.failed) { + throw new Error(MAGNETIC_CALIBRATION_ERRORS[reading.code] ?? MAGNETIC_CALIBRATION_STEPS[8]); + } + } + } + private async currentProfile(): Promise { const reply = await this.request(READ.activeProfile); this.activeProfile = Math.max(1, reply[0]); @@ -572,7 +715,7 @@ export class LamzuHidClient { const reply = this.copyDataView(await this.device.receiveFeatureReport(REPORT_ID)); if (reply[0] === STATUS.unsupported) throw new Error(this.describe(spec, "is not supported by this mouse")); if (reply[0] === STATUS.ok && reply[4] === spec.page && reply[5] === spec.command) { - const length = Math.min(reply[3], PACKET_LENGTH - HEADER_LENGTH); + const length = spec.full ? PACKET_LENGTH - HEADER_LENGTH : Math.min(reply[3], PACKET_LENGTH - HEADER_LENGTH); const payload = reply.slice(HEADER_LENGTH, HEADER_LENGTH + length); if (!spec.matchesReply || spec.matchesReply(payload)) return payload; } diff --git a/src/drivers/lamzu/magnetic.test.ts b/src/drivers/lamzu/magnetic.test.ts new file mode 100644 index 00000000..01a567a3 --- /dev/null +++ b/src/drivers/lamzu/magnetic.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { LamzuHidClient } from "./hid.ts"; +import { LAMZU_VENDOR_ID, lamzuProduct } from "@openmouse/protocol/lamzu"; + +const globals = globalThis as { window?: { setTimeout: typeof setTimeout } }; +globals.window ??= { setTimeout }; + +interface Options { productId?: number; calibrated?: boolean; calibration?: Array> } + +function v4gt(options: Options = {}) { + const state = { + types: [3, 3], calibrated: options.calibrated ?? true, recal: false, + travel: [[4, 9], [4, 9]], rapid: [0, 0], writes: [] as string[], step: 0, + }; + const sent: Uint8Array[] = []; + const device = { + vendorId: LAMZU_VENDOR_ID, productId: options.productId ?? 0x0098, productName: "Leviathan V4 GT", opened: true, + collections: [{ usagePage: 0xff00, usage: 1, type: 1, children: [], featureReports: [{ reportId: 0, items: [{ reportSize: 8, reportCount: 64 }] }], inputReports: [], outputReports: [] }], + open: async () => {}, close: async () => {}, + sendFeatureReport: async (_id: number, data: Uint8Array) => { sent.push(new Uint8Array(data)); }, + receiveFeatureReport: async () => { + const q = sent[sent.length - 1]!; + const page = q[4]!, command = q[5]!, a = q.subarray(6); + const reply = new Uint8Array(64); + reply[0] = 0xa1; reply[3] = q[3]!; reply[4] = page; reply[5] = command; + const key = (page << 8) | command; + let payload: number[] = [1, 1, 0, 1]; + switch (key) { + case 0x0081: payload = [0, 0, 1, 2, 3, 4]; break; + case 0x0083: payload = [0, 76]; break; + case 0x0085: payload = [1]; break; + case 0x0087: payload = [1, 0, 60]; break; + case 0x0088: payload = [1, 4]; break; + case 0x0181: payload = [1, 1, 0x06, 0x40, 0x06, 0x40]; break; + case 0x0182: payload = [1, 1]; break; + case 0x0180: payload = [1, 0x01]; break; + case 0x0188: payload = [1, 0x01]; break; + case 0x0086 + 0x0300: payload = [0, 0, 0, 0, state.calibrated ? 0 : 1, state.recal ? 64 : 0]; break; + case 0x0095: payload = [0, state.types[0]!, state.types[1]!]; break; + case 0x0082: payload = [0, 255]; break; + case 0x0015: state.types = [a[1]!, a[2]!]; state.writes.push("types"); break; + case 0x0385: payload = [a[0]!, a[1]!, state.travel[a[1]! - 1]![0]!, state.travel[a[1]! - 1]![1]!]; break; + case 0x0305: state.travel[a[1]! - 1] = [a[2]!, a[3]!]; state.writes.push("travel"); break; + case 0x0098: payload = [a[0]!, a[1]!, state.rapid[a[1]! - 1]!]; break; + case 0x0018: state.rapid[a[1]! - 1] = a[2]!; state.writes.push("rapid"); break; + case 0x0307: { + payload = new Array(40).fill(0); + if (a[0] === 5 || a[0] === 3) { + payload[21] = 7; + const frame = options.calibration?.[Math.min(state.step, options.calibration.length - 1)] ?? { state: 7 }; + state.step += 1; + payload[1] = frame.state ?? 0; payload[3] = frame.code ?? 0; payload[19] = frame.mask ?? 0; + payload[24] = frame.left ?? 0; payload[25] = frame.right ?? 0; + } + break; + } + } + reply.set(payload, 6); + if (key !== 0x0307) reply[3] = payload.length; + return new DataView(reply.buffer); + }, + addEventListener: () => {}, removeEventListener: () => {}, + } as unknown as HIDDevice; + return { device, sent, state }; +} + +test("the Leviathan V4 GT is catalogued as a magnetic RAWM mouse on both connections", () => { + assert.equal(lamzuProduct(LAMZU_VENDOR_ID, 0x0098)?.magnetic, true); + assert.equal(lamzuProduct(LAMZU_VENDOR_ID, 0x0099)?.wireless, true); + assert.equal(lamzuProduct(LAMZU_VENDOR_ID, 0x0098)?.brand, "RAWM"); + assert.equal(LamzuHidClient.isSupported(v4gt().device), true); +}); + +test("status carries the magnetic buttons", async () => { + const { device } = v4gt(); + const status = await new LamzuHidClient(device).readStatus(); + assert.equal(status.brand, "RAWM"); + const magnetic = status.magneticButtons!; + assert.deepEqual(magnetic.buttons.map((button) => button.switchType), ["magnetic", "magnetic"]); + assert.deepEqual(magnetic.buttons.map((button) => [button.triggerPoint, button.releasePoint, button.rapidTrigger, button.rapidTriggerEnabled]), [[4, null, null, false], [4, null, null, false]]); + assert.equal(magnetic.calibration, "calibrated"); + assert.equal(magnetic.rapidTriggerUnit, "ms"); +}); + +test("an uncalibrated mouse reads as optical and needing calibration", async () => { + const { device } = v4gt({ calibrated: false }); + const magnetic = (await new LamzuHidClient(device).readStatus()).magneticButtons!; + assert.deepEqual(magnetic.buttons.map((button) => button.switchType), ["optical", "optical"]); + assert.equal(magnetic.calibration, "needed"); +}); + +test("trigger, release and rapid trigger writes are read back", async () => { + const { device, state } = v4gt(); + const client = new LamzuHidClient(device); + assert.equal(await client.setMagneticTriggerPoint(1, 7), 7); + assert.deepEqual(state.travel[1], [7, 9]); + assert.equal(await client.setMagneticReleasePoint(1, 5), 5); + assert.deepEqual(state.travel[1], [7, 5]); + await client.setMagneticReleasePoint(1, null); + assert.deepEqual(state.travel[1], [7, 9]); + assert.deepEqual(await client.setMagneticRapidTrigger(0, true, 6), { enabled: true, level: 6 }); + assert.equal(state.rapid[0], 6); + await client.setMagneticRapidTrigger(0, false, 6); + assert.equal(state.rapid[0], 0); + await assert.rejects(() => client.setMagneticTriggerPoint(0, 11), /1 to 10/); + await assert.rejects(() => client.setMagneticReleasePoint(0, 9), /other than 9/); + await assert.rejects(() => client.setMagneticRapidTrigger(0, true, 16), /1 to 15/); +}); + +test("the switch type is written with the model id and confirmed", async () => { + const { device, state } = v4gt(); + await new LamzuHidClient(device).setMagneticSwitchTypes(["optical", "magnetic"]); + assert.deepEqual(state.types, [2, 3]); +}); + +test("Magnetic is refused until the switches are calibrated", async () => { + const { device, state } = v4gt({ calibrated: false }); + const client = new LamzuHidClient(device); + await assert.rejects(() => client.setMagneticSwitchTypes(["magnetic", "optical"]), /Calibrate/); + assert.equal(state.writes.includes("types"), false); + await client.setMagneticSwitchTypes(["optical", "optical"]); +}); + +test("calibration reports progress and resolves on success", async () => { + const { device } = v4gt({ calibration: [ + { state: 0 }, { state: 10, mask: 12 }, { state: 10, mask: 16, left: 60, right: 40 }, { state: 11, mask: 3, left: 100, right: 100 }, { state: 6 }, { state: 7 }, + ] }); + const messages: string[] = []; + await new LamzuHidClient(device).calibrateMagneticButtons((progress) => messages.push(progress.message)); + assert.ok(messages.some((message) => message.includes("Left: Press and hold fully"))); + assert.equal(messages.at(-1), "Calibration Successful"); +}); + +test("a failed calibration rejects with the mouse's own reason", async () => { + const { device } = v4gt({ calibration: [{ state: 0 }, { state: 10, mask: 12 }, { state: 8, code: 4 }] }); + await assert.rejects(() => new LamzuHidClient(device).calibrateMagneticButtons(() => {}), /Unstable calibration data/); +}); diff --git a/src/drivers/magnetic.ts b/src/drivers/magnetic.ts new file mode 100644 index 00000000..047ebaf9 --- /dev/null +++ b/src/drivers/magnetic.ts @@ -0,0 +1,22 @@ +import type { MagneticCalibrationProgress } from "./mouse-types.ts"; + +/** What a driver offers when its mouse has magnetic buttons (see `MouseStatus.magneticButtons`). */ +export interface MagneticButtonClient { + /** Button 0 is the left click, 1 the right click. */ + setMagneticTriggerPoint(button: 0 | 1, point: number): Promise; + setMagneticRapidTrigger(button: 0 | 1, enabled: boolean, level: number): Promise<{ enabled: boolean; level: number }>; + /** Null makes the release follow the trigger point. Absent when the mouse has no release travel. */ + setMagneticReleasePoint?(button: 0 | 1, point: number | null): Promise; + /** Absent when the mouse has no optical switch to fall back on. */ + setMagneticSwitchTypes?(types: readonly ["magnetic" | "optical", "magnetic" | "optical"]): Promise; + /** Absent when the mouse cannot be calibrated from here. Rejects with the reason on failure. */ + calibrateMagneticButtons?(onProgress: (progress: MagneticCalibrationProgress) => void, signal?: AbortSignal): Promise; + /** Live press depth, 0 to 100 per button, for mice that stream it. */ + onButtonDepth?(listener: (left: number, right: number) => void): () => void; +} + +export function isMagneticButtonClient(client: unknown): client is MagneticButtonClient { + const candidate = client as Partial | null; + return typeof candidate?.setMagneticTriggerPoint === "function" + && typeof candidate.setMagneticRapidTrigger === "function"; +} diff --git a/src/drivers/mouse-types.ts b/src/drivers/mouse-types.ts index af653248..ddc7bcef 100644 --- a/src/drivers/mouse-types.ts +++ b/src/drivers/mouse-types.ts @@ -152,6 +152,44 @@ export interface AtkReceiverInfo { pairingSecondsRemaining: number | null; } +export interface MagneticButtonStatus { + /** Null when the mouse has a single switch type or did not report it. */ + switchType: "magnetic" | "optical" | null; + /** Trigger point, inside `triggerPointRange`. Null when the mouse returned no valid value. */ + triggerPoint: number | null; + /** Release travel. Null when the release follows the trigger point or is unsupported. */ + releasePoint?: number | null; + /** Rapid trigger value, inside `rapidTriggerRange`. Null when unknown. */ + rapidTrigger: number | null; + rapidTriggerEnabled: boolean | null; +} + +export interface MagneticButtonsStatus { + /** Left button first, right button second. */ + buttons: MagneticButtonStatus[]; + triggerPointRange: { min: number; max: number }; + releasePointRange?: { min: number; max: number }; + /** Null when rapid trigger cannot be changed on this connection. */ + rapidTriggerRange: { min: number; max: number } | null; + rapidTriggerUnit: "level" | "ms"; + /** Whether rapid trigger has its own on/off switch, or is off at its lowest value. */ + rapidTriggerSwitch: boolean; + canChooseSwitchType: boolean; + /** Null when the mouse cannot be calibrated on this connection. */ + calibration: "calibrated" | "needed" | "unknown" | null; + /** The mouse streams live press depth (0 to 100) while buttons are pressed. */ + liveDepth: boolean; +} + +export interface MagneticCalibrationProgress { + left: number; + right: number; + message: string; + /** 1-based step out of `steps`. */ + step: number; + steps: number; +} + export interface MouseStatus { brand: "RAWM" | "Motospeed" | "Logitech" | "Pulsar" | "Endgame Gear" | "WLMouse" | "G-Wolves" | "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "Orbital" | "Razer" | "Teevolution" | "ATK" | "VXE" | "VGN" | "VAXEE" | "Finalmouse" | "Keychron" | "moddoMOUSE" | "Ninjutso" | "Zaunkoenig" | "Fantech" | "Wooting" | "WALLHACK" | "SteelSeries" | "Glorious" | "MCHOSE" | "K-snake" | "Noir Gear" | "Lingbao" | "GearHub" | "AJAZZ" | "Corsair" | "Microsoft" | "Dareu" | "Redragon" | "Incott" | "HyperX" | "ASUS" | "Ryunix" | "Delux" | "GravaStar" | "IPI" | "Rapoo" | "Fater" | "Cooler Master"; name: string; @@ -178,6 +216,12 @@ export interface MouseStatus { rapidTriggerEnabled?: boolean; }>; }; + /** + * Magnetic (Hall-effect) primary-button settings: where the click registers, + * rapid trigger, and whether each button uses its magnetic or optical switch. + * Absent on mice without magnetic buttons. + */ + magneticButtons?: MagneticButtonsStatus; pollingRateHz: number; supportedPollingRates?: number[]; activeProfile: number | null; diff --git a/src/gwolves/index.ts b/src/gwolves/index.ts index 3c5f46c1..98feb2c3 100644 --- a/src/gwolves/index.ts +++ b/src/gwolves/index.ts @@ -35,6 +35,7 @@ export const GWOLVES_COMMAND = { profile: 0x0e, firmware: 0x12, dongleFirmware: 0x1d, + calibrate: 0x2e, } as const; export const GWOLVES_ADDRESS = { @@ -49,8 +50,19 @@ export const GWOLVES_ADDRESS = { angleSnapping: 0xaf, rippleControl: 0xb1, performanceMode: 0xb5, + leftTrigger: 0xef, + leftRapidTrigger: 0xf1, + rightTrigger: 0xf5, + rightRapidTrigger: 0xf7, } as const; +/** Input report 8 whose first byte is this is a mouse-initiated notification, not a reply. */ +export const GWOLVES_NOTIFICATION = 0x0a; +export const GWOLVES_TRIGGER_POINT_MAX = 20; +export const GWOLVES_RAPID_TRIGGER_MAX = 10; +/** Model ids the receiver reports for the magnetic generation (MID in G-Wolves' env-models.json). */ +export const GWOLVES_HTS_PLUS_PRO_MODEL_ID = 11; + export interface GWolvesBattery { percent: number; charging: boolean; @@ -219,3 +231,78 @@ export function gwolvesDecodeProfile(profile: Uint8Array): GWolvesProfile { performanceMode: boolean(GWOLVES_ADDRESS.performanceMode), }; } + +/** + * Magnetic buttons (HTS Plus Pro). Every value is a [v, 0x55 - v] pair like the + * other flash scalars. Reads that fail the pair check are unknown, not zero: + * G-Wolves' own web driver would show the most sensitive setting for them. + */ +export function gwolvesDecodeTriggerPoint(pair: Uint8Array | readonly number[]): number | null { + const value = gwolvesUnpackScalar(pair[0] ?? 0, pair[1] ?? 0); + return value === null || value >= GWOLVES_TRIGGER_POINT_MAX ? null : value + 1; +} + +export function gwolvesEncodeTriggerPoint(point: number): number { + if (!Number.isInteger(point) || point < 1 || point > GWOLVES_TRIGGER_POINT_MAX) { + throw new RangeError(`G-Wolves trigger point must be 1 to ${GWOLVES_TRIGGER_POINT_MAX}.`); + } + return point - 1; +} + +export interface GWolvesRapidTrigger { + enabled: boolean; + level: number; +} + +export function gwolvesDecodeRapidTrigger(pair: Uint8Array | readonly number[]): GWolvesRapidTrigger | null { + const value = gwolvesUnpackScalar(pair[0] ?? 0, pair[1] ?? 0); + return value === null ? null : { enabled: (value >> 7) === 1, level: value & 0x0f }; +} + +export function gwolvesEncodeRapidTrigger(enabled: boolean, level: number): number { + if (!Number.isInteger(level) || level < 1 || level > GWOLVES_RAPID_TRIGGER_MAX) { + throw new RangeError(`G-Wolves rapid trigger must be 1 to ${GWOLVES_RAPID_TRIGGER_MAX}.`); + } + return ((enabled ? 1 : 0) << 7) | level; +} + +/** Starts the magnetic button calibration. The mouse sends no result; press depth notifications follow. */ +export function gwolvesBuildCalibrationPayload(): Uint8Array { + const payload = gwolvesBuildSimplePayload(GWOLVES_COMMAND.calibrate); + payload[4] = 10; + payload[5] = 1; + payload[7] = 3; + return finalize(payload); +} + +/** Press depth of the left and right button, 0 to 100, from a notification. Null for any other report. */ +export function gwolvesParseButtonDepth(report: Uint8Array): { left: number; right: number } | null { + if (report.length < 15 || report[0] !== GWOLVES_NOTIFICATION || report[5] !== 0) return null; + return { left: report[13]! & 0x7f, right: report[14]! & 0x7f }; +} + +/** The receiver is shared by every model; this handshake says which mouse is behind it. */ +export function gwolvesBuildModelPayload(random: readonly number[]): Uint8Array { + const payload = gwolvesBuildSimplePayload(GWOLVES_COMMAND.handshake); + payload[4] = 8; + for (let index = 0; index < 4; index += 1) payload[5 + index] = (random[index] ?? 0) & 0xff; + return finalize(payload); +} + +export function gwolvesParseModelId(response: Uint8Array): { modelId: number; fourKReceiver: boolean } | null { + if (!gwolvesReportChecksumIsValid(response) || response[0] !== GWOLVES_COMMAND.handshake) return null; + return { modelId: response[10] === 0 ? 2 : response[10]!, fourKReceiver: response[11] === 1 }; +} + +export type GWolvesCalibrationStage = "pressBoth" | "releaseBoth" | "done"; + +/** + * Follows press depth through the calibration the way G-Wolves' web driver + * does: both buttons to full depth, then both back to zero. The mouse never + * reports a result, so "done" only means a full press and release was seen. + */ +export function gwolvesAdvanceCalibration(stage: GWolvesCalibrationStage, depth: { left: number; right: number }): GWolvesCalibrationStage { + if (stage === "pressBoth" && depth.left >= 100 && depth.right >= 100) return "releaseBoth"; + if (stage === "releaseBoth" && depth.left === 0 && depth.right === 0) return "done"; + return stage; +} diff --git a/src/gwolves/magnetic.test.ts b/src/gwolves/magnetic.test.ts new file mode 100644 index 00000000..887a56fe --- /dev/null +++ b/src/gwolves/magnetic.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + gwolvesAdvanceCalibration, + gwolvesBuildCalibrationPayload, + gwolvesBuildModelPayload, + gwolvesDecodeRapidTrigger, + gwolvesDecodeTriggerPoint, + gwolvesEncodeRapidTrigger, + gwolvesEncodeTriggerPoint, + gwolvesParseButtonDepth, + gwolvesReportChecksumIsValid, +} from "@openmouse/protocol/gwolves"; + +test("trigger points store the slider value minus one and reject a broken pair", () => { + assert.equal(gwolvesEncodeTriggerPoint(1), 0); + assert.equal(gwolvesEncodeTriggerPoint(20), 19); + assert.throws(() => gwolvesEncodeTriggerPoint(0)); + assert.equal(gwolvesDecodeTriggerPoint([4, 0x55 - 4]), 5); + assert.equal(gwolvesDecodeTriggerPoint([7, 7]), null); + assert.equal(gwolvesDecodeTriggerPoint([40, 0x55 - 40]), null); +}); + +test("rapid trigger keeps the switch in the top bit and the level below it", () => { + assert.equal(gwolvesEncodeRapidTrigger(true, 7), 0x87); + assert.equal(gwolvesEncodeRapidTrigger(false, 10), 10); + assert.deepEqual(gwolvesDecodeRapidTrigger([0x87, (0x55 - 0x87) & 0xff]), { enabled: true, level: 7 }); + assert.equal(gwolvesDecodeRapidTrigger([1, 1]), null); +}); + +test("calibration and model requests are valid report 8 frames", () => { + const calibration = gwolvesBuildCalibrationPayload(); + assert.equal(gwolvesReportChecksumIsValid(calibration), true); + assert.deepEqual([calibration[0], calibration[4], calibration[5], calibration[7]], [0x2e, 10, 1, 3]); + const model = gwolvesBuildModelPayload([1, 2, 3, 4]); + assert.equal(gwolvesReportChecksumIsValid(model), true); + assert.deepEqual([...model.subarray(5, 9)], [1, 2, 3, 4]); +}); + +test("press depth only comes from the depth notification", () => { + const report = new Uint8Array(16); + report[0] = 10; report[13] = 0x80 | 33; report[14] = 100; + assert.deepEqual(gwolvesParseButtonDepth(report), { left: 33, right: 100 }); + report[5] = 1; + assert.equal(gwolvesParseButtonDepth(report), null); + assert.equal(gwolvesParseButtonDepth(new Uint8Array(16)), null); +}); + +test("calibration needs both buttons fully down and then both fully up", () => { + let stage = gwolvesAdvanceCalibration("pressBoth", { left: 100, right: 40 }); + assert.equal(stage, "pressBoth"); + stage = gwolvesAdvanceCalibration(stage, { left: 100, right: 100 }); + assert.equal(stage, "releaseBoth"); + assert.equal(gwolvesAdvanceCalibration(stage, { left: 0, right: 5 }), "releaseBoth"); + assert.equal(gwolvesAdvanceCalibration(stage, { left: 0, right: 0 }), "done"); +}); diff --git a/src/lamzu/index.ts b/src/lamzu/index.ts index ef281c5e..05ebd291 100644 --- a/src/lamzu/index.ts +++ b/src/lamzu/index.ts @@ -1,11 +1,14 @@ export * from "../compx/codec.js"; export * from "./atlantis.js"; export * from "./lunafury.js"; +export * from "./magnetic.js"; export interface LamzuProduct { model: string; wireless: boolean; pollingRates: readonly number[]; - brand?: "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury"; + brand?: "Lamzu" | "CRDRAKO" | "Attack Shark" | "LunaFury" | "RAWM"; + /** Left and right buttons are magnetic switches (Leviathan V4 GT). */ + magnetic?: boolean; uiFamily?: string; mouseTarget?: number; maxDpi?: number; @@ -29,6 +32,16 @@ export const LUNAFURY_PRODUCT_IDS = [0x0032, 0x0033, 0x0054, 0x0084] as const; // Only the R5 Ultra has been seen on hardware. export const ATTACKSHARK_PRODUCT_IDS = [0x0046, 0x0047, 0x0021, 0x0022, 0x003a, 0x003b] as const; export const LAMZU_PRODUCTS: ReadonlyMap = new Map([ + // RAWM's Leviathan V4 GT answers the same page-command framing. Product ids + // come from the V4 GT's web hub; not yet tried on hardware. + [0x0098, { + brand: "RAWM", model: "Leviathan V4 GT", wireless: false, + pollingRates: RATES_8K_FULL, maxDpi: 45000, magnetic: true, + }], + [0x0099, { + brand: "RAWM", model: "Leviathan V4 GT", wireless: true, + pollingRates: RATES_8K_FULL, maxDpi: 45000, magnetic: true, + }], [0x001c, { model: "Maya X", wireless: false, pollingRates: RATES_1K }], [0x001d, { model: "Maya X", wireless: true, pollingRates: RATES_1K }], [0x001e, { model: "Maya X", wireless: true, pollingRates: RATES_8K }], diff --git a/src/lamzu/magnetic.ts b/src/lamzu/magnetic.ts new file mode 100644 index 00000000..30b0585a --- /dev/null +++ b/src/lamzu/magnetic.ts @@ -0,0 +1,125 @@ +/** + * Magnetic button commands used by RAWM's Leviathan V4 GT. The mouse speaks the + * same page-command framing as the Lamzu receivers; these are the extra pages + * for trigger travel, rapid trigger, the magnetic/optical switch choice and the + * manual calibration, read from the V4 GT's web hub. + */ +import type { CompaxRequest } from "../compx/codec.js"; + +export const MAGNETIC_PAGE = { device: 0x00, magnetic: 0x03 } as const; +export const MAGNETIC_SWITCH = { optical: 2, magnetic: 3 } as const; +export const MAGNETIC_TRAVEL_MAX = 10; +export const MAGNETIC_RAPID_TRIGGER_MAX_MS = 15; +/** Release travel 9 means the release follows the trigger point. */ +export const MAGNETIC_RELEASE_FOLLOWS = 9; + +const MOUSE = 0x02; + +export const magneticRead = { + fault: { target: MOUSE, page: MAGNETIC_PAGE.magnetic, command: 0x86, length: 6, args: [] }, + switchTypes: { target: MOUSE, page: MAGNETIC_PAGE.device, command: 0x95, length: 8, args: [] }, + eid: { target: MOUSE, page: MAGNETIC_PAGE.device, command: 0x82, length: 2, args: [] }, + travel: (profile: number, key: number): CompaxRequest => + ({ target: MOUSE, page: MAGNETIC_PAGE.magnetic, command: 0x85, length: 4, args: [profile, key] }), + rapidTrigger: (profile: number, key: number): CompaxRequest => + ({ target: MOUSE, page: MAGNETIC_PAGE.device, command: 0x98, length: 3, args: [profile, key] }), +} as const; + +export const magneticWrite = { + switchTypes: (eid: number, left: number, right: number): CompaxRequest => + ({ target: MOUSE, page: MAGNETIC_PAGE.device, command: 0x15, length: 8, args: [eid & 0xff, left, right] }), + travel: (profile: number, key: number, press: number, release: number): CompaxRequest => + ({ target: MOUSE, page: MAGNETIC_PAGE.magnetic, command: 0x05, length: 4, args: [profile, key, press, release] }), + rapidTrigger: (profile: number, key: number, milliseconds: number): CompaxRequest => + ({ target: MOUSE, page: MAGNETIC_PAGE.device, command: 0x18, length: 3, args: [profile, key, milliseconds] }), + /** `state` 5 asks the layout, 4 and 1 start a two or one button calibration, 5 and 3 poll it. */ + calibration: (state: number): CompaxRequest => + ({ target: MOUSE, page: MAGNETIC_PAGE.magnetic, command: 0x07, length: 3, args: [state] }), +} as const; + +export function magneticDecodeFault(payload: Uint8Array): { calibrated: boolean; needsRecalibration: boolean } { + return { calibrated: (payload[4] ?? 1) === 0, needsRecalibration: payload[5] === 64 }; +} + +export function magneticDecodeSwitchTypes(payload: Uint8Array): [number, number] { + return [payload[1] ?? 0, payload[2] ?? 0]; +} + +export function magneticDecodeTravel(payload: Uint8Array): { press: number; release: number } { + return { press: payload[2] ?? 0, release: payload[3] ?? 0 }; +} + +export function magneticDecodeRapidTrigger(payload: Uint8Array): number { + return payload[2] ?? 0; +} + +/** The hub's identifier for the mouse model, echoed back when the switch type is written. */ +export function magneticDecodeEid(payload: Uint8Array): number { + const low = payload[0] ?? 0; + const high = payload[1] ?? 0; + const value = high === 0 || high === 0xff ? low : high + (low << 8); + return value === 0xff ? 0 : value; +} + +export interface MagneticCalibrationReading { + state: number; + code: number; + twoButtons: boolean; + /** Which button the mouse is waiting on: bit 0/1 release left/right, bit 2/3 press left/right. */ + mask: number; + left: number; + right: number; + /** Progress of a single-button calibration, 0 to 100. */ + progress: number; +} + +export function magneticDecodeCalibration(payload: Uint8Array): MagneticCalibrationReading { + return { + state: payload[1] ?? 0, + code: payload[3] ?? 0, + twoButtons: payload[21] === 7, + mask: payload[19] ?? 0, + left: payload[24] ?? 0, + right: payload[25] ?? 0, + progress: payload[2] ?? 0, + }; +} + +export const MAGNETIC_CALIBRATION_STATE = { success: 7, failed: 8, cancelled: 9 } as const; + +export const MAGNETIC_CALIBRATION_STEPS: Readonly> = { + 1: "Release the left and right buttons completely", + 2: "Press and hold the left button fully", + 3: "Release the left button", + 4: "Press and hold the right button fully", + 5: "Release the right button", + 6: "Verifying and saving...", + 7: "Calibration Successful", + 8: "Calibration Failed", + 9: "Calibration Cancelled", + 10: "Press and hold both buttons fully", + 11: "Release both buttons; either can be released first", +}; + +export const MAGNETIC_CALIBRATION_ERRORS: Readonly> = { + 2: "Calibration timed out. Fully press and release as prompted, then recalibrate.", + 3: "Abnormal button travel. Fully press and release, then recalibrate.", + 4: "Unstable calibration data. Keep the mouse still and operate the buttons smoothly, then recalibrate.", + 8: "Calibration canceled. Previous settings restored.", + 9: "Mouse is asleep. Move it to wake it, then recalibrate.", + 10: "USB connection changed during calibration. Keep the connection unchanged, then recalibrate.", + 11: "Another magnetic switch calibration is in progress. Try again later.", + 12: "Magnetic switch signal unavailable. Check the battery and connection, then recalibrate.", + 13: "Failed to save calibration. Previous settings restored. Please recalibrate.", + 14: "Button not fully released or release positions inconsistent. Fully release both buttons, then recalibrate.", +}; + +/** What each button is being asked to do, from the mask the mouse reports during a two button calibration. */ +export function magneticCalibrationPrompt(mask: number): string | null { + if (!mask) return null; + const side = (press: number, release: number): string => + mask & press ? "Press and hold fully" : mask & release ? "Release" : mask & 16 ? "Keep steady" : ""; + const left = side(4, 1); + const right = side(8, 2); + return [left && `Left: ${left}`, right && `Right: ${right}`].filter(Boolean).join("\n") || null; +}