Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 150 additions & 2 deletions src/drivers/gwolves/hid.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -117,7 +146,9 @@ export class GWolvesHidClient {

async readStatus(): Promise<MouseStatus> {
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();
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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<boolean> {
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<MagneticButtonsStatus> {
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<number> {
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<void> {
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<void>((resolve) => window.setTimeout(resolve, this.calibrationTiming.settleMs));
settled = true;
seen = false;
const started = Date.now();
last = started;
report();
while (stage !== "done") {
await new Promise<void>((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<void> {
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();
Expand Down
151 changes: 151 additions & 0 deletions src/drivers/gwolves/magnetic.test.ts
Original file line number Diff line number Diff line change
@@ -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]]);
});
4 changes: 4 additions & 0 deletions src/drivers/gwolves/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ 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;

export const GWOLVES_PRODUCTS: ReadonlyMap<number, GWolvesProduct> = 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
Expand Down
Loading
Loading