Skip to content

Commit a6dcb18

Browse files
Add deviceSelectionMode to MicrobitWebUSBConnectionOptions (#60)
Added a new configuration (`deviceSelectionMode`) for how a device should be selected. `DeviceSelectionMode.AlwaysAsk` - Attempts to connect to known device, otherwise asks which device to connect with. `DeviceSelectionMode.UseAnyAllowed` - Attempts to connect to known device, otherwise attempts to connect with any allowed devices. If that fails, asks which device to connect with.
1 parent 869c6a9 commit a6dcb18

File tree

4 files changed

+284
-17
lines changed

4 files changed

+284
-17
lines changed

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from "./usb-radio-bridge.js";
4343
import {
4444
createWebUSBConnection,
45+
DeviceSelectionMode,
4546
MicrobitWebUSBConnection,
4647
MicrobitWebUSBConnectionOptions,
4748
} from "./usb.js";
@@ -58,6 +59,7 @@ export {
5859
createWebBluetoothConnection,
5960
createWebUSBConnection,
6061
DeviceConnectionEventMap,
62+
DeviceSelectionMode,
6163
DeviceError,
6264
FlashDataError,
6365
FlashEvent,

lib/usb.test.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* with a tweak to Buffer.
1111
*/
1212
import { ConnectionStatus, ConnectionStatusEvent } from "./device.js";
13-
import { createWebUSBConnection } from "./usb.js";
13+
import { applyDeviceFilters, createWebUSBConnection } from "./usb.js";
1414
import { beforeAll, expect, vi, describe, it } from "vitest";
1515

1616
vi.mock("./webusb-device-wrapper", () => ({
@@ -77,3 +77,110 @@ describeDeviceOnly("MicrobitWebUSBConnection (WebUSB supported)", () => {
7777
]);
7878
});
7979
});
80+
81+
interface MockUSBDeviceConfig {
82+
vendorId?: number;
83+
productId?: number;
84+
serialNumber?: string;
85+
interfaceClass?: number;
86+
interfaceSubclass?: number;
87+
interfaceProtocol?: number;
88+
interfaceName?: string;
89+
interfaces?: MockUSBInterface[];
90+
}
91+
interface MockUSBInterface {
92+
interfaceNumber: number;
93+
alternates: MockUSBAlternateInterface[];
94+
}
95+
interface MockUSBAlternateInterface {
96+
interfaceClass: number;
97+
interfaceSubclass: number;
98+
interfaceProtocol: number;
99+
interfaceName?: string;
100+
}
101+
102+
const mockDevice = (config?: MockUSBDeviceConfig) => ({
103+
vendorId: config?.vendorId || 0x2341,
104+
productId: config?.productId || 0x0043,
105+
serialNumber: config?.serialNumber || "MOCK123456",
106+
configuration: {
107+
interfaces: config?.interfaces || [
108+
{
109+
alternates: [
110+
{
111+
alternateSetting: 0,
112+
interfaceClass: config?.interfaceClass || 2,
113+
interfaceSubclass: config?.interfaceSubclass || 2,
114+
interfaceProtocol: config?.interfaceProtocol || 0,
115+
},
116+
],
117+
},
118+
],
119+
},
120+
});
121+
122+
const filter: USBDeviceFilter = {
123+
classCode: 123,
124+
productId: 456,
125+
protocolCode: 789,
126+
serialNumber: "012",
127+
subclassCode: 345,
128+
vendorId: 690,
129+
};
130+
131+
describe("applyDevicesFilter", () => {
132+
it("has no filter", () => {
133+
const device = mockDevice() as USBDevice;
134+
expect(applyDeviceFilters(device, [], [])).toEqual(true);
135+
});
136+
it("satisfies filter", () => {
137+
const device = mockDevice({
138+
interfaceClass: filter.classCode,
139+
productId: filter.productId,
140+
interfaceProtocol: filter.protocolCode,
141+
serialNumber: filter.serialNumber,
142+
interfaceSubclass: filter.subclassCode,
143+
vendorId: filter.vendorId,
144+
}) as USBDevice;
145+
expect(applyDeviceFilters(device, [filter], [])).toEqual(true);
146+
});
147+
it("does not satisfies filter", () => {
148+
const device = mockDevice({
149+
interfaceClass: filter.classCode,
150+
productId: filter.productId,
151+
interfaceProtocol: filter.protocolCode,
152+
serialNumber: "something else",
153+
interfaceSubclass: filter.subclassCode,
154+
vendorId: filter.vendorId,
155+
}) as USBDevice;
156+
expect(applyDeviceFilters(device, [filter], [])).toEqual(false);
157+
});
158+
it("satisfies exclusion filter", () => {
159+
const device = mockDevice({
160+
interfaceClass: filter.classCode,
161+
productId: filter.productId,
162+
interfaceProtocol: filter.protocolCode,
163+
serialNumber: filter.serialNumber,
164+
interfaceSubclass: filter.subclassCode,
165+
vendorId: filter.vendorId,
166+
}) as USBDevice;
167+
expect(applyDeviceFilters(device, [], [filter])).toEqual(false);
168+
});
169+
it("satifies filter and does not satisfy exclusion filter", () => {
170+
const device = mockDevice({
171+
interfaceClass: filter.classCode,
172+
productId: filter.productId,
173+
interfaceProtocol: filter.protocolCode,
174+
serialNumber: filter.serialNumber,
175+
interfaceSubclass: filter.subclassCode,
176+
vendorId: filter.vendorId,
177+
}) as USBDevice;
178+
expect(
179+
applyDeviceFilters(
180+
device,
181+
[filter],
182+
[{ ...filter, serialNumber: "not satisfied" }],
183+
),
184+
).toEqual(true);
185+
});
186+
});

lib/usb.ts

Lines changed: 158 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,35 @@ export const isChromeOS105 = (): boolean => {
3737
return /CrOS/.test(userAgent) && /Chrome\/105\b/.test(userAgent);
3838
};
3939

40+
const defaultFilters = [{ vendorId: 0x0d28, productId: 0x0204 }];
41+
42+
export enum DeviceSelectionMode {
43+
/**
44+
* Attempts to connect to known device, otherwise asks which device to
45+
* connect to.
46+
*/
47+
AlwaysAsk = "AlwaysAsk",
48+
49+
/**
50+
* Attempts to connect to known device, otherwise attempts to connect to any
51+
* allowed devices. If that fails, asks which device to connect to.
52+
*/
53+
UseAnyAllowed = "UseAnyAllowed",
54+
}
55+
4056
export interface MicrobitWebUSBConnectionOptions {
4157
// We should copy this type when extracting a library, and make it optional.
4258
// Coupling for now to make it easy to evolve.
4359

44-
logging: Logging;
60+
/**
61+
* Determines logging behaviour for events, errors, and logs.
62+
*/
63+
logging?: Logging;
64+
65+
/**
66+
* Determines how a device should be selected.
67+
*/
68+
deviceSelectionMode?: DeviceSelectionMode;
4569
}
4670

4771
export interface MicrobitWebUSBConnection
@@ -176,16 +200,17 @@ class MicrobitWebUSBConnectionImpl
176200
};
177201

178202
private logging: Logging;
203+
private deviceSelectionMode: DeviceSelectionMode;
179204

180205
private addedListeners: Record<string, number> = {
181206
serialdata: 0,
182207
};
183208

184-
constructor(
185-
options: MicrobitWebUSBConnectionOptions = { logging: new NullLogging() },
186-
) {
209+
constructor(options: MicrobitWebUSBConnectionOptions = {}) {
187210
super();
188-
this.logging = options.logging;
211+
this.logging = options.logging || new NullLogging();
212+
this.deviceSelectionMode =
213+
options.deviceSelectionMode || DeviceSelectionMode.AlwaysAsk;
189214
}
190215

191216
private log(v: any) {
@@ -460,25 +485,86 @@ class MicrobitWebUSBConnectionImpl
460485
}
461486

462487
private async connectInternal(): Promise<void> {
463-
if (!this.connection) {
464-
const device = await this.chooseDevice();
465-
this.connection = new DAPWrapper(device, this.logging);
488+
if (!this.connection && this.device) {
489+
this.connection = new DAPWrapper(this.device, this.logging);
490+
await withTimeout(this.connection.reconnectAsync(), 10_000);
491+
} else if (!this.connection) {
492+
await this.connectWithOtherDevice();
493+
} else {
494+
await withTimeout(this.connection.reconnectAsync(), 10_000);
466495
}
467-
await withTimeout(this.connection.reconnectAsync(), 10_000);
468496
if (this.addedListeners.serialdata && !this.flashing) {
469497
this.startSerialInternal();
470498
}
471499
this.setStatus(ConnectionStatus.CONNECTED);
472500
}
473501

474-
private async chooseDevice(): Promise<USBDevice> {
475-
if (this.device) {
476-
return this.device;
502+
private async connectWithOtherDevice(): Promise<void> {
503+
if (this.deviceSelectionMode === DeviceSelectionMode.UseAnyAllowed) {
504+
await this.attemptConnectAllowedDevices();
505+
}
506+
if (!this.connection) {
507+
this.device = await this.chooseDevice();
508+
this.connection = new DAPWrapper(this.device, this.logging);
509+
await withTimeout(this.connection.reconnectAsync(), 10_000);
510+
}
511+
}
512+
513+
// Based on: https://github.com/microsoft/pxt/blob/ab97a2422879824c730f009b15d4bf446b0e8547/pxtlib/webusb.ts#L361
514+
private async attemptConnectAllowedDevices(): Promise<void> {
515+
const pairedDevices = await this.getFilteredAllowedDevices();
516+
for (const device of pairedDevices) {
517+
const connection = await this.attemptDeviceConnection(device);
518+
if (connection) {
519+
this.device = device;
520+
this.connection = connection;
521+
return;
522+
}
477523
}
524+
}
525+
526+
// Based on: https://github.com/microsoft/pxt/blob/ab97a2422879824c730f009b15d4bf446b0e8547/pxtlib/webusb.ts#L530
527+
private async getFilteredAllowedDevices(): Promise<USBDevice[]> {
528+
this.log("Retrieving previously paired USB devices");
529+
try {
530+
const devices = await this.withEnrichedErrors(() =>
531+
navigator.usb?.getDevices(),
532+
);
533+
if (devices === undefined) {
534+
return [];
535+
}
536+
const filteredDevices = devices.filter((device) =>
537+
applyDeviceFilters(device, defaultFilters, this.exclusionFilters ?? []),
538+
);
539+
return filteredDevices;
540+
} catch (error: any) {
541+
this.log(`Failed to retrieve paired devices: ${error.message}`);
542+
return [];
543+
}
544+
}
545+
546+
private async attemptDeviceConnection(
547+
device: USBDevice,
548+
): Promise<DAPWrapper | undefined> {
549+
this.log(
550+
`Attempting connection to: ${device.manufacturerName} ${device.productName}`,
551+
);
552+
this.log(`Serial number: ${device.serialNumber}`);
553+
try {
554+
const connection = new DAPWrapper(device, this.logging);
555+
await withTimeout(connection.reconnectAsync(), 10_000);
556+
return connection;
557+
} catch (error: any) {
558+
this.log(`Connection attempt failed: ${error.message}`);
559+
return;
560+
}
561+
}
562+
563+
private async chooseDevice(): Promise<USBDevice> {
478564
this.dispatchTypedEvent("beforerequestdevice", new BeforeRequestDevice());
479565
this.device = await navigator.usb.requestDevice({
480566
exclusionFilters: this.exclusionFilters,
481-
filters: [{ vendorId: 0x0d28, productId: 0x0204 }],
567+
filters: defaultFilters,
482568
});
483569
this.dispatchTypedEvent("afterrequestdevice", new AfterRequestDevice());
484570
return this.device;
@@ -509,6 +595,65 @@ class MicrobitWebUSBConnectionImpl
509595
}
510596
}
511597

598+
/**
599+
* Applying WebUSB device filter. Exported for testing.
600+
* Based on: https://wicg.github.io/webusb/#enumeration
601+
*/
602+
export const applyDeviceFilters = (
603+
device: USBDevice,
604+
filters: USBDeviceFilter[],
605+
exclusionFilters: USBDeviceFilter[],
606+
) => {
607+
return (
608+
(filters.length === 0 ||
609+
filters.some((filter) => matchFilter(device, filter))) &&
610+
(exclusionFilters.length === 0 ||
611+
exclusionFilters.every((filter) => !matchFilter(device, filter)))
612+
);
613+
};
614+
615+
const matchFilter = (device: USBDevice, filter: USBDeviceFilter) => {
616+
if (filter.vendorId && device.vendorId !== filter.vendorId) {
617+
return false;
618+
}
619+
if (filter.productId && device.productId !== filter.productId) {
620+
return false;
621+
}
622+
if (filter.serialNumber && device.serialNumber !== filter.serialNumber) {
623+
return false;
624+
}
625+
return hasMatchingInterface(device, filter);
626+
};
627+
628+
const hasMatchingInterface = (device: USBDevice, filter: USBDeviceFilter) => {
629+
if (
630+
filter.classCode === undefined &&
631+
filter.subclassCode === undefined &&
632+
filter.protocolCode === undefined
633+
) {
634+
return true;
635+
}
636+
if (!device.configuration?.interfaces) {
637+
return false;
638+
}
639+
return device.configuration.interfaces.some((configInterface) => {
640+
return configInterface.alternates?.some((alternate) => {
641+
const classCodeNotMatch =
642+
filter.classCode !== undefined &&
643+
alternate.interfaceClass !== filter.classCode;
644+
const subClassCodeNotMatch =
645+
filter.subclassCode !== undefined &&
646+
alternate.interfaceSubclass !== filter.subclassCode;
647+
const protocolCodeNotMatch =
648+
filter.protocolCode !== undefined &&
649+
alternate.interfaceProtocol !== filter.protocolCode;
650+
return (
651+
!classCodeNotMatch || !subClassCodeNotMatch || !protocolCodeNotMatch
652+
);
653+
});
654+
});
655+
};
656+
512657
const genericErrorSuggestingReconnect = (e: any) =>
513658
new DeviceError({
514659
code: "reconnect-microbit",

src/demo.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ import { createUniversalHexFlashDataSource } from "../lib/hex-flash-data-source"
1919
import { MagnetometerDataEvent } from "../lib/magnetometer";
2020
import { SerialDataEvent } from "../lib/serial-events";
2121
import { UARTDataEvent } from "../lib/uart";
22-
import { createWebUSBConnection, MicrobitWebUSBConnection } from "../lib/usb";
22+
import {
23+
createWebUSBConnection,
24+
DeviceSelectionMode,
25+
MicrobitWebUSBConnection,
26+
} from "../lib/usb";
2327
import {
2428
createRadioBridgeConnection,
2529
MicrobitRadioBridgeConnection,
@@ -40,11 +44,20 @@ const createConnections = (
4044
case "bluetooth":
4145
return { type, connection: createWebBluetoothConnection() };
4246
case "usb":
43-
return { type, connection: createWebUSBConnection() };
47+
return {
48+
type,
49+
connection: createWebUSBConnection({
50+
deviceSelectionMode: DeviceSelectionMode.UseAnyAllowed,
51+
}),
52+
};
4453
case "radio":
4554
// This only works with the local-sensor hex.
4655
// To use with a remote micro:bit we need a UI flow that grabs and sets the remote id.
47-
const connection = createRadioBridgeConnection(createWebUSBConnection());
56+
const connection = createRadioBridgeConnection(
57+
createWebUSBConnection({
58+
deviceSelectionMode: DeviceSelectionMode.UseAnyAllowed,
59+
}),
60+
);
4861
connection.setRemoteDeviceId(0);
4962
return { type, connection };
5063
}

0 commit comments

Comments
 (0)