Skip to content

Commit ffbd75c

Browse files
authored
Merge pull request #1185 from thebryce15/fix/razer-bridge-hotplug-probe
fix(device): preserve working Razer connections on hotplug
2 parents 1f312f1 + 6fac919 commit ffbd75c

3 files changed

Lines changed: 100 additions & 7 deletions

File tree

‎src/device/controller.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
listLogicalDevices,
1717
logicalDeviceGroups,
1818
pickLogicalDevice,
19+
prepareAutoConnectClient,
1920
type PulsarClient,
2021
type SupportedClient,
2122
} from "./device-clients";
@@ -3528,10 +3529,34 @@ async function loadKeyboardTrigger(client: KeyboardClient): Promise<void> {
35283529
}
35293530

35303531
/** Queue a device activation so only one keyboard/mouse switch runs at a time. */
3531-
function activateClient(target: SupportedClient | KeyboardClient): Promise<void> {
3532+
function activateClient(target: SupportedClient | KeyboardClient, autoConnect = false): Promise<void> {
35323533
const run = activationQueue.then(async () => {
35333534
activationInProgress = true;
35343535
try {
3536+
if (autoConnect && !isKeyboardClient(target)) {
3537+
if (target.device === activeDevice) {
3538+
await refreshSidebar();
3539+
return;
3540+
}
3541+
const prepared = await prepareAutoConnectClient(target);
3542+
if (!prepared) {
3543+
await refreshSidebar();
3544+
return;
3545+
}
3546+
// A probe opens the candidate; discard it if unplugged while it answered.
3547+
if (prepared !== target) {
3548+
const devices = await navigator.hid?.getDevices().catch(() => []) ?? [];
3549+
if (!devices.includes(prepared.device)) {
3550+
if (activeDevice !== prepared.device) await prepared.close().catch(() => undefined);
3551+
await refreshSidebar();
3552+
return;
3553+
}
3554+
}
3555+
target = prepared;
3556+
deviceStatusText = st("ctl.newDevice");
3557+
readStatus = st("ctl.reading", { name: statusNameForClient(target) });
3558+
emit();
3559+
}
35353560
if (isKeyboardClient(target)) await activateKeyboardClientNow(target);
35363561
else await activateClientNow(target);
35373562
} finally {
@@ -3635,10 +3660,7 @@ function handleHidConnect(event: HIDConnectionEvent): void {
36353660
return;
36363661
}
36373662

3638-
deviceStatusText = st("ctl.newDevice");
3639-
readStatus = st("ctl.reading", { name: statusNameForClient(client) });
3640-
emit();
3641-
void activateClient(client).catch((error: unknown) => {
3663+
void activateClient(client, true).catch((error: unknown) => {
36423664
deviceStatusText = st("ctl.connFailed");
36433665
readStatus = error instanceof Error ? error.message : st("ctl.unableRead");
36443666
toastForError("Connection failed", error);

‎src/device/device-clients.test.ts‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
22
import test from "node:test";
33

44
import { describeHidDevice } from "./hid-diagnostics.ts";
5-
import { createSupportedClient, listLogicalDevices, logicalDeviceGroups } from "./device-clients.ts";
5+
import { createSupportedClient, listLogicalDevices, logicalDeviceGroups, prepareAutoConnectClient } from "./device-clients.ts";
66

77
function supportedWebHidDevice(options: {
88
vendorId?: number;
@@ -108,3 +108,67 @@ test("Bridge devices keep their native per-physical-device identity", () => {
108108
assert.equal(logicalDeviceGroups(devices).length, 2);
109109
assert.equal(listLogicalDevices(devices).length, 2);
110110
});
111+
112+
function answeringBridgeRazer(key: string, answers: boolean, usages = [[0x01, 0x02]]): HIDDevice {
113+
let reply = new Uint8Array(90);
114+
const device = {
115+
...bridgeDevice(key, "Razer Basilisk V3 Pro 35K"),
116+
productId: 0x00cc,
117+
collections: usages.map(([usagePage, usage]) => ({
118+
usagePage, usage, inputReports: [], outputReports: [], featureReports: [], children: [],
119+
})),
120+
opened: false,
121+
async open() { this.opened = true; },
122+
async close() { this.opened = false; },
123+
async sendFeatureReport(reportId: number, data: Uint8Array) {
124+
assert.equal(reportId, 0);
125+
assert.equal(data[6], 0x00);
126+
assert.equal(data[7], 0x81, "automatic selection must only probe firmware");
127+
if (!answers) throw new Error("HidD_SetFeature: Incorrect function");
128+
reply = new Uint8Array(data);
129+
reply[0] = 0x02;
130+
},
131+
async receiveFeatureReport() { return new DataView(reply.buffer); },
132+
};
133+
return device as unknown as HIDDevice;
134+
}
135+
136+
test("automatic Bridge selection rejects a dead Razer sibling without closing the working interface", async (t) => {
137+
Object.defineProperty(globalThis, "window", { configurable: true, value: { setTimeout } });
138+
t.after(() => Reflect.deleteProperty(globalThis, "window"));
139+
const working = answeringBridgeRazer("working", true);
140+
await working.open();
141+
const sibling = answeringBridgeRazer("sibling", false, [[0x01, 0x80], [0x0c, 0x01]]);
142+
const candidate = createSupportedClient(sibling);
143+
assert.ok(candidate);
144+
145+
assert.equal(await prepareAutoConnectClient(candidate), null);
146+
assert.equal(sibling.opened, false);
147+
assert.equal(working.opened, true);
148+
});
149+
150+
test("automatic Bridge selection keeps answering Razer interfaces regardless of collection shape", async (t) => {
151+
Object.defineProperty(globalThis, "window", { configurable: true, value: { setTimeout } });
152+
t.after(() => Reflect.deleteProperty(globalThis, "window"));
153+
for (const usages of [[[0x01, 0x02]], [[0x0c, 0x01]], []]) {
154+
const device = answeringBridgeRazer("working", true, usages);
155+
const candidate = createSupportedClient(device);
156+
assert.ok(candidate);
157+
const prepared = await prepareAutoConnectClient(candidate);
158+
assert.ok(prepared);
159+
assert.equal(prepared.device, device);
160+
assert.equal(device.opened, true);
161+
await prepared.close();
162+
}
163+
});
164+
165+
test("automatic selection leaves specialized Razer and WebHID clients on their existing paths", async () => {
166+
for (const device of [
167+
{ ...bridgeDevice("v4"), productId: 0x00e5 },
168+
supportedWebHidDevice(),
169+
]) {
170+
const client = createSupportedClient(device as HIDDevice);
171+
assert.ok(client);
172+
assert.equal(await prepareAutoConnectClient(client), client);
173+
}
174+
});

‎src/device/device-clients.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
COBRA_PRODUCT_ID,
77
RazerCobraHidClient,
88
} from "@openmouse/protocol/drivers/razer/cobra-hid";
9-
import { RazerHidClient } from "@openmouse/protocol/drivers/razer/hid";
9+
import { RazerHidClient, razerProbeControlInterface } from "@openmouse/protocol/drivers/razer/hid";
1010
import {
1111
RazerViperHidClient,
1212
VIPER_PRODUCT_ID,
@@ -52,6 +52,13 @@ export function createSupportedClient(device: HIDDevice): SupportedClient | null
5252
return registryCreateSupportedClient(device) ?? bridgeRazerClient(device);
5353
}
5454

55+
export async function prepareAutoConnectClient(client: SupportedClient): Promise<SupportedClient | null> {
56+
if (!(client instanceof RazerHidClient)
57+
|| (client.device as NativeBridgeDevice).openMouseTransport !== "bridge") return client;
58+
// Bridge can expose a sibling with the same VID/PID that cannot answer commands.
59+
return razerProbeControlInterface([client.device]);
60+
}
61+
5562
export type { KeyboardClient };
5663

5764
export function isKeyboardClient(client: SupportedClient | KeyboardClient): client is KeyboardClient {

0 commit comments

Comments
 (0)