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
49 changes: 49 additions & 0 deletions src/drivers/glorious/classic-hid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,55 @@ test("recognizes a Model D Wireless whose config lives on the 0xffff:0 collectio
assert.equal(GloriousClassicHidClient.isSupported(device), true);
});

test("recognizes a Model O V2 Wired whose config channel is numbered report 7", () => {
// A real 0x320f:0x823a rejected connection entirely: its `usage 0xff01:1`
// collection carries feature report id 7, not the unnumbered report (0)
// every other classic-family device uses.
const { device } = fakeDevice(VENDOR_ID.gloriousClassicIWired, 0x823a, "Model O 2 Wired Mouse");
(device.collections[0].featureReports[0] as { reportId: number }).reportId = 7;
assert.equal(GloriousClassicHidClient.isSupported(device), true);
});

test("a numbered config report is used for every write, not the unnumbered default", async () => {
const { device, sent } = fakeDevice(VENDOR_ID.gloriousClassicIWired, 0x823a);
(device.collections[0].featureReports[0] as { reportId: number }).reportId = 7;
const client = new GloriousClassicHidClient(device);

await client.setDpi(1600);
await client.setPollingRate(500);
await client.setLiftOffDistance("High");

assert.ok(sent.length >= 3);
for (const report of sent) assert.equal(report.reportId, 7, "every write should use the discovered report id");
});

test("a report length other than 64 bytes refuses writes instead of guessing at the layout", async () => {
// The real 0x320f:0x823a unit's numbered report 7 declares a 263-byte
// length. Resizing the driver's 64-byte payload to fit no longer errors
// at the WebHID level, but a diagnostic confirmed the mouse silently
// ignores it - the write ACKs and nothing on the mouse changes. Refuse
// instead of sending a payload with an unconfirmed byte layout.
const { device, sent } = fakeDevice(VENDOR_ID.gloriousClassicIWired, 0x823a);
const report = device.collections[0].featureReports[0] as { reportId: number; items: Array<{ reportSize: number; reportCount: number }> };
report.reportId = 7;
report.items = [{ reportSize: 8, reportCount: 263 }];
const client = new GloriousClassicHidClient(device);

assert.deepEqual(client.getDpiOptions(), []);
assert.deepEqual(client.getSupportedPollingRates(), []);
await assert.rejects(() => client.setDpi(1600), /not confirmed/);
await assert.rejects(() => client.setPollingRate(500), /not confirmed/);
await assert.rejects(() => client.setLiftOffDistance("High"), /not confirmed/);
await assert.rejects(() => client.setDebounceTime(4), /not confirmed/);
await assert.rejects(() => client.setRgb({ effect: "solid", rate: 0, colors: ["#ff0000"] }), /not confirmed/);
assert.equal(sent.length, 0, "nothing should be sent once the report length is unconfirmed");

const status = await client.readStatus();
assert.equal(status.dpi, 0);
assert.equal(status.pollingRateHz, 0);
assert.equal(status.liftOffDistance, null);
});

test("rejects an unrecognized VID/PID pair", () => {
const { device } = fakeDevice(VENDOR_ID.gloriousO3, 0x1234);
assert.equal(GloriousClassicHidClient.isSupported(device), false);
Expand Down
144 changes: 108 additions & 36 deletions src/drivers/glorious/classic-hid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,18 @@ import { GLORIOUS_CLASSIC_PRODUCTS, VENDOR_ID } from "../vendors.ts";
* D-, Model I, Model O V2 — plus the newer "core2" 8000Hz-class mice, Model
* O3 Wireless and Model D 2 PRO 4K/8KHz Edition, on a reduced feature set —
* see the `generation` doc comment on `GLORIOUS_CLASSIC_PRODUCTS` in
* vendors.ts for why). The config channel is an unnumbered 64-byte feature
* report; see ../../glorious-classic/index.ts for the payload layout.
* vendors.ts for why). The config channel is a feature report, usually 64
* bytes and unnumbered (id 0), but a real "Model O 2 Wired Mouse"
* (0x320f:0x823a) instead carries it as numbered report 7 at a 263-byte
* declared length. Both the report id and byte length are read from the
* device's own descriptor rather than assumed, so it connects - but every
* payload below is only reverse-engineered against the 64-byte case, and a
* diagnostic confirmed the mouse silently ignores a 64-byte payload just
* zero-padded out to 263 (WebHID's write succeeds; nothing changes on the
* mouse). See isConfirmedReportLength()'s doc comment: writes are refused
* for any length this driver hasn't confirmed a real byte layout for,
* rather than guessing again. See ../../glorious-classic/index.ts for the
* payload layout, which does not depend on the report id.
*
* DPI, polling rate, lift-off distance, and RGB are all write-only on this
* protocol (neither glorious-ctl nor mxw, the two tools this was ported
Expand Down Expand Up @@ -82,10 +92,20 @@ const DEFAULT_STATE: GloriousClassicState = {
export class GloriousClassicHidClient {
readonly pollIntervalMs = 0;
readonly device: HIDDevice;
/**
* The vendor collection's feature report id and byte length, read from the
* device at connect time rather than assumed - see isConfirmedReportLength()
* for why a length other than 64 means writes get refused instead of guessed at.
*/
private readonly reportId: number;
private readonly reportLength: number;
private lastRgb: GloriousClassicRgb = GLORIOUS_CLASSIC_DEFAULT_RGB;

constructor(device: HIDDevice) {
this.device = device;
const config = GloriousClassicHidClient.findConfigReport(device);
this.reportId = config?.reportId ?? GLORIOUS_CLASSIC_REPORT_ID;
this.reportLength = config?.length ?? GLORIOUS_CLASSIC_PACKET_LENGTH;
}

static isSupported(device: HIDDevice): boolean {
Expand All @@ -94,13 +114,41 @@ export class GloriousClassicHidClient {
&& device.vendorId !== VENDOR_ID.gloriousClassicIWired
&& device.vendorId !== VENDOR_ID.gloriousO3) return false;
if (!GLORIOUS_CLASSIC_PRODUCTS.has(device.productId)) return false;
return device.collections.some((collection) => this.hasConfigReport(collection));
return this.findConfigReport(device) !== null;
}

/** The feature-report id and byte length carried by the config channel collection, or null. */
private static findConfigReport(device: HIDDevice): { reportId: number; length: number } | null {
for (const collection of device.collections) {
const found = this.findConfigReportIn(collection);
if (found !== null) return found;
}
return null;
}

private static findConfigReportIn(collection: HIDCollectionInfo): { reportId: number; length: number } | null {
if (CLASSIC_USAGE_PAGES.includes(collection.usagePage) && collection.featureReports.length > 0) {
const report = collection.featureReports[0];
const bits = report.items.reduce((sum, item) => sum + (item.reportSize ?? 0) * (item.reportCount ?? 0), 0);
return { reportId: report.reportId, length: bits > 0 ? Math.ceil(bits / 8) : GLORIOUS_CLASSIC_PACKET_LENGTH };
}
for (const child of collection.children) {
const found = this.findConfigReportIn(child);
if (found !== null) return found;
}
return null;
}

private static hasConfigReport(collection: HIDCollectionInfo): boolean {
const matchesHere = CLASSIC_USAGE_PAGES.includes(collection.usagePage)
&& collection.featureReports.some((report) => report.reportId === GLORIOUS_CLASSIC_REPORT_ID);
return matchesHere || collection.children.some((child) => this.hasConfigReport(child));
/** Resizes a payload (always built at GLORIOUS_CLASSIC_PACKET_LENGTH) to the device's real declared length. */
private fitPayload(payload: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
if (payload.length === this.reportLength) return payload;
const resized = new Uint8Array(this.reportLength);
resized.set(payload.subarray(0, Math.min(payload.length, this.reportLength)));
return resized;
}

private async send(payload: Uint8Array<ArrayBuffer>): Promise<void> {
await this.device.sendFeatureReport(this.reportId, this.fitPayload(payload));
}

async open(): Promise<void> {
Expand Down Expand Up @@ -129,15 +177,38 @@ export class GloriousClassicHidClient {
return GLORIOUS_CLASSIC_PRODUCTS.get(this.device.productId)?.generation === "core2";
}

/**
* Every payload builder in glorious-classic/index.ts was reverse-engineered
* against the 64-byte feature report every classic-line unit confirmed so
* far uses. A real 0x320f:0x823a instead declares a 263-byte report;
* resizing our 64-byte payload to fit sends without a WebHID error, but a
* diagnostic confirmed the mouse ignores it outright - the firmware ACKs
* the write and changes nothing. A report length this driver hasn't seen a
* real byte layout for is therefore not writable yet, even though it
* connects and its report id is known - see [[glorious-classic-protocol]]
* in memory. Refuse instead of guessing again until a capture of the
* device's own official software gives the real layout.
*/
private isConfirmedReportLength(): boolean {
return this.reportLength === GLORIOUS_CLASSIC_PACKET_LENGTH;
}

private assertWritable(feature: string): void {
if (this.isCore2()) throw new Error(`${feature} is not confirmed on this mouse's newer protocol generation yet.`);
if (!this.isConfirmedReportLength()) {
throw new Error(`${feature} is not confirmed on this unit's ${this.reportLength}-byte feature report yet.`);
}
}

getDpiOptions(): number[] {
if (this.isCore2()) return [];
if (this.isCore2() || !this.isConfirmedReportLength()) return [];
const options: number[] = [];
for (let dpi = GLORIOUS_CLASSIC_DPI_MIN; dpi <= GLORIOUS_CLASSIC_DPI_MAX; dpi += 50) options.push(dpi);
return options;
}

getSupportedPollingRates(): number[] {
if (this.isCore2()) return [];
if (this.isCore2() || !this.isConfirmedReportLength()) return [];
return GLORIOUS_CLASSIC_POLLING_RATES.map(([, hertz]) => hertz).sort((left, right) => left - right);
}

Expand All @@ -147,7 +218,8 @@ export class GloriousClassicHidClient {
const battery = this.isWireless() ? await this.readBattery().catch(() => null) : null;
const wireless = this.isWireless();
const core2 = this.isCore2();
const liftOffDistance = core2 ? null : LIFT_OFF_DISTANCES.find(([mm]) => mm === state.lodMm)?.[1] ?? "Medium";
const restricted = core2 || !this.isConfirmedReportLength();
const liftOffDistance = restricted ? null : LIFT_OFF_DISTANCES.find(([mm]) => mm === state.lodMm)?.[1] ?? "Medium";
return {
brand: "Glorious",
name: this.displayName(),
Expand All @@ -156,10 +228,10 @@ export class GloriousClassicHidClient {
batteryState: battery
? (battery.state === "Normal" && battery.charging ? "Charging" : BATTERY_STATE_LABEL[battery.state])
: "Unknown",
dpi: core2 ? 0 : state.stageDpis[state.activeStage] ?? state.stageDpis[0] ?? 800,
pollingRateHz: core2 ? 0 : gloriousClassicDecodePollingRate(state.pollingIntervalMs) ?? 1000,
dpi: restricted ? 0 : state.stageDpis[state.activeStage] ?? state.stageDpis[0] ?? 800,
pollingRateHz: restricted ? 0 : gloriousClassicDecodePollingRate(state.pollingIntervalMs) ?? 1000,
supportedPollingRates: this.getSupportedPollingRates(),
activeProfile: core2 ? null : state.profileId,
activeProfile: restricted ? null : state.profileId,
connectionType: wireless ? "Wireless" : "Wired",
connectionDetail: wireless
? "2.4 GHz / Bluetooth · settings are write-only, not read back"
Expand All @@ -171,64 +243,57 @@ export class GloriousClassicHidClient {
}

async setDpi(dpi: number): Promise<number> {
if (this.isCore2()) {
throw new Error("DPI is not confirmed on this mouse's newer protocol generation yet.");
}
this.assertWritable("DPI");
if (!Number.isFinite(dpi) || dpi < GLORIOUS_CLASSIC_DPI_MIN || dpi > GLORIOUS_CLASSIC_DPI_MAX) {
throw new Error(`DPI must be between ${GLORIOUS_CLASSIC_DPI_MIN} and ${GLORIOUS_CLASSIC_DPI_MAX}.`);
}
const state = this.loadState();
const rounded = Math.round(dpi);
state.stageDpis[state.activeStage] = rounded;
await this.open();
await this.device.sendFeatureReport(
GLORIOUS_CLASSIC_REPORT_ID,
buildGloriousClassicDpiStagesPayload(state.stageDpis, state.profileId),
);
await this.device.sendFeatureReport(
GLORIOUS_CLASSIC_REPORT_ID,
buildGloriousClassicActiveStagePayload(state.activeStage + 1, state.profileId),
);
await this.send(buildGloriousClassicDpiStagesPayload(state.stageDpis, state.profileId));
await this.send(buildGloriousClassicActiveStagePayload(state.activeStage + 1, state.profileId));
this.saveState(state);
return rounded;
}

async setPollingRate(pollingRateHz: number): Promise<number> {
if (this.isCore2()) {
throw new Error("Polling rate is not confirmed on this mouse's newer protocol generation yet.");
}
this.assertWritable("Polling rate");
const intervalMs = gloriousClassicEncodePollingRate(pollingRateHz);
if (intervalMs === null) throw new Error(`This mouse does not support ${pollingRateHz} Hz.`);
const state = this.loadState();
await this.open();
await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicPollingRatePayload(intervalMs));
await this.send(buildGloriousClassicPollingRatePayload(intervalMs));
state.pollingIntervalMs = intervalMs;
this.saveState(state);
return pollingRateHz;
}

async setLiftOffDistance(value: NonNullable<MouseStatus["liftOffDistance"]>): Promise<NonNullable<MouseStatus["liftOffDistance"]>> {
if (this.isCore2()) {
throw new Error("Lift-off distance is not confirmed on this mouse's newer protocol generation yet.");
}
this.assertWritable("Lift-off distance");
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 state = this.loadState();
await this.open();
await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicLiftOffPayload(millimetres));
await this.send(buildGloriousClassicLiftOffPayload(millimetres));
state.lodMm = millimetres;
this.saveState(state);
return value;
}

async setDebounceTime(milliseconds: number): Promise<number> {
// Unlike DPI/polling/LOD, core2 does write debounce - so this only gates
// on report length, not isCore2().
if (!this.isConfirmedReportLength()) {
throw new Error(`Debounce is not confirmed on this unit's ${this.reportLength}-byte feature report yet.`);
}
if (!Number.isFinite(milliseconds) || milliseconds < 0 || milliseconds > GLORIOUS_CLASSIC_DEBOUNCE_MAX_MS) {
throw new Error(`Debounce must be between 0 and ${GLORIOUS_CLASSIC_DEBOUNCE_MAX_MS} ms.`);
}
const state = this.loadState();
const clamped = Math.round(milliseconds);
await this.open();
await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicDebouncePayload(clamped, state.profileId));
await this.send(buildGloriousClassicDebouncePayload(clamped, state.profileId));
state.debounceMs = clamped;
this.saveState(state);
return clamped;
Expand All @@ -239,16 +304,21 @@ export class GloriousClassicHidClient {
}

async setRgb(rgb: GloriousClassicRgb): Promise<GloriousClassicRgb> {
// Unlike DPI/polling/LOD, core2 does write RGB - so this only gates on
// report length, not isCore2().
if (!this.isConfirmedReportLength()) {
throw new Error(`RGB is not confirmed on this unit's ${this.reportLength}-byte feature report yet.`);
}
await this.open();
await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicRgbPayload(rgb));
await this.send(buildGloriousClassicRgbPayload(rgb));
this.lastRgb = rgb;
return rgb;
}

private async readBattery(): Promise<GloriousClassicBattery> {
await this.device.sendFeatureReport(GLORIOUS_CLASSIC_REPORT_ID, buildGloriousClassicBatteryRequestPayload());
await this.send(buildGloriousClassicBatteryRequestPayload());
await this.delay(BATTERY_RESPONSE_DELAY_MS);
const view = await this.device.receiveFeatureReport(GLORIOUS_CLASSIC_REPORT_ID);
const view = await this.device.receiveFeatureReport(this.reportId);
const body = new Uint8Array(view.buffer, view.byteOffset, Math.min(view.byteLength, GLORIOUS_CLASSIC_PACKET_LENGTH));
return parseGloriousClassicBatteryResponse(body);
}
Expand All @@ -268,6 +338,8 @@ export class GloriousClassicHidClient {
forceShowBattery: this.isWireless(),
statusNote: this.isCore2()
? "This mouse's newer protocol generation only has confirmed commands for RGB, debounce, and battery — DPI, polling rate, and lift-off distance aren't wired in yet."
: !this.isConfirmedReportLength()
? `This mouse connects, but its ${this.reportLength}-byte feature report uses a byte layout this driver hasn't confirmed yet — no settings can be changed until a real capture is available.`
: "DPI, polling rate, lift-off distance and RGB are written to this mouse but never read back.",
};
}
Expand Down
13 changes: 11 additions & 2 deletions src/drivers/vendors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ export const GLORIOUS_CLASSIC_PRODUCTS: ReadonlyMap<number, { name: string; wire
[0x2014, { name: "Model D- Wireless", wireless: false, generation: "core1" }],
[0x2025, { name: "Model D- Wireless", wireless: true, generation: "core1" }],
[0x2033, { name: "Model O 2 Wireless", wireless: true, generation: "core1" }],
// "core1" here is unconfirmed, despite the name overlap with 0x2033 above -
// a real unit is on a completely different VID (gloriousClassicIWired,
// 0x320f, not gloriousClassic's 0x258a) and its feature report declares
// 263 bytes, not the 64 every other core1 device (including 0x2033) uses.
// A diagnostic confirmed the driver's core1 payloads write without error
// but do nothing on the mouse - see isConfirmedReportLength() in
// classic-hid.ts, which is what actually gates writes at runtime (not this
// label). Model I 2 Wireless/Wired below (0x821a/0x831a) share this same
// VID and are equally unconfirmed for the same reason.
[0x823a, { name: "Model O V2 Wired", wireless: false, generation: "core1" }],
[0x2015, { name: "Model O Pro", wireless: false, generation: "core1" }],
[0x2027, { name: "Model O Pro Wireless receiver", wireless: true, generation: "core1" }],
Expand All @@ -260,8 +269,8 @@ export const GLORIOUS_CLASSIC_PRODUCTS: ReadonlyMap<number, { name: string; wire
[0x201a, { name: "Model D 2 PRO", wireless: false, generation: "core1" }],
[0x2034, { name: "Model D 2 PRO Wireless receiver", wireless: true, generation: "core1" }],
[0x1503, { name: "Model I", wireless: false, generation: "core1" }],
[0x821a, { name: "Model I 2 Wireless", wireless: false, generation: "core1" }],
[0x831a, { name: "Model I 2 Wired", wireless: false, generation: "core1" }],
[0x821a, { name: "Model I 2 Wireless", wireless: false, generation: "core1" }], // unconfirmed - see the note on 0x823a above
[0x831a, { name: "Model I 2 Wired", wireless: false, generation: "core1" }], // unconfirmed - see the note on 0x823a above
// core2 — RGB/debounce/battery only, see the doc comment above.
[0xa312, { name: "Model O3 Wireless", wireless: true, generation: "core2" }],
[0xa300, { name: "Model O3 Wireless receiver", wireless: true, generation: "core2" }],
Expand Down