react-native-web-serial-api 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +188 -117
  2. package/TESTING.md +417 -176
  3. package/android/build.gradle +14 -0
  4. package/android/src/main/java/dev/webserialapi/NativeUsbSerialModule.java +74 -11
  5. package/android/src/main/java/dev/webserialapi/PortPickerActivity.java +61 -59
  6. package/bin/expose-serial.js +205 -0
  7. package/lib/commonjs/UsbSerial.js +1 -1
  8. package/lib/commonjs/WebSerial.js +110 -26
  9. package/lib/commonjs/WebSerial.js.map +1 -1
  10. package/lib/commonjs/index.js +2 -2
  11. package/lib/commonjs/index.js.map +1 -1
  12. package/lib/commonjs/lib/event-target.js +3 -1
  13. package/lib/commonjs/lib/event-target.js.map +1 -1
  14. package/lib/commonjs/lib/web-streams.js +42 -0
  15. package/lib/commonjs/lib/web-streams.js.map +1 -0
  16. package/lib/commonjs/testing/device-fixture.js +70 -0
  17. package/lib/commonjs/testing/device-fixture.js.map +1 -0
  18. package/lib/commonjs/testing/expose.js +91 -0
  19. package/lib/commonjs/testing/expose.js.map +1 -0
  20. package/lib/commonjs/testing/harness.js +98 -0
  21. package/lib/commonjs/testing/harness.js.map +1 -0
  22. package/lib/commonjs/testing/{virtual-serial.js → in-memory-serial-transport.js} +66 -28
  23. package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
  24. package/lib/commonjs/testing/index.js +100 -17
  25. package/lib/commonjs/testing/index.js.map +1 -1
  26. package/lib/commonjs/testing/install-in-memory-serial-transport.js +54 -0
  27. package/lib/commonjs/testing/install-in-memory-serial-transport.js.map +1 -0
  28. package/lib/commonjs/testing/serial-client.js +277 -0
  29. package/lib/commonjs/testing/serial-client.js.map +1 -0
  30. package/lib/commonjs/testing/{serial-device.js → simulated-device.js} +17 -17
  31. package/lib/commonjs/testing/simulated-device.js.map +1 -0
  32. package/lib/commonjs/testing/test-suite.js +142 -0
  33. package/lib/commonjs/testing/test-suite.js.map +1 -0
  34. package/lib/commonjs/transport.js +3 -3
  35. package/lib/commonjs/websocket/WebSocketSerialTransport.js +659 -0
  36. package/lib/commonjs/websocket/WebSocketSerialTransport.js.map +1 -0
  37. package/lib/commonjs/websocket/bridge.js +234 -0
  38. package/lib/commonjs/websocket/bridge.js.map +1 -0
  39. package/lib/commonjs/websocket/index.js +33 -0
  40. package/lib/commonjs/websocket/index.js.map +1 -0
  41. package/lib/commonjs/websocket/protocol.js +55 -0
  42. package/lib/commonjs/websocket/protocol.js.map +1 -0
  43. package/lib/commonjs/websocket/serial-device-bridge.js +130 -0
  44. package/lib/commonjs/websocket/serial-device-bridge.js.map +1 -0
  45. package/lib/typescript/src/UsbSerial.d.ts +1 -1
  46. package/lib/typescript/src/WebSerial.d.ts +7 -7
  47. package/lib/typescript/src/WebSerial.d.ts.map +1 -1
  48. package/lib/typescript/src/index.d.ts +1 -1
  49. package/lib/typescript/src/index.d.ts.map +1 -1
  50. package/lib/typescript/src/lib/event-target.d.ts +2 -0
  51. package/lib/typescript/src/lib/event-target.d.ts.map +1 -1
  52. package/lib/typescript/src/lib/web-streams.d.ts +9 -0
  53. package/lib/typescript/src/lib/web-streams.d.ts.map +1 -0
  54. package/lib/typescript/src/testing/device-fixture.d.ts +70 -0
  55. package/lib/typescript/src/testing/device-fixture.d.ts.map +1 -0
  56. package/lib/typescript/src/testing/expose.d.ts +71 -0
  57. package/lib/typescript/src/testing/expose.d.ts.map +1 -0
  58. package/lib/typescript/src/testing/harness.d.ts +34 -0
  59. package/lib/typescript/src/testing/harness.d.ts.map +1 -0
  60. package/lib/typescript/src/testing/{virtual-serial.d.ts → in-memory-serial-transport.d.ts} +37 -26
  61. package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
  62. package/lib/typescript/src/testing/index.d.ts +18 -8
  63. package/lib/typescript/src/testing/index.d.ts.map +1 -1
  64. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts +25 -0
  65. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts.map +1 -0
  66. package/lib/typescript/src/testing/serial-client.d.ts +62 -0
  67. package/lib/typescript/src/testing/serial-client.d.ts.map +1 -0
  68. package/lib/typescript/src/testing/{serial-device.d.ts → simulated-device.d.ts} +23 -23
  69. package/lib/typescript/src/testing/simulated-device.d.ts.map +1 -0
  70. package/lib/typescript/src/testing/test-suite.d.ts +75 -0
  71. package/lib/typescript/src/testing/test-suite.d.ts.map +1 -0
  72. package/lib/typescript/src/transport.d.ts +3 -3
  73. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts +111 -0
  74. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts.map +1 -0
  75. package/lib/typescript/src/websocket/bridge.d.ts +66 -0
  76. package/lib/typescript/src/websocket/bridge.d.ts.map +1 -0
  77. package/lib/typescript/src/websocket/index.d.ts +19 -0
  78. package/lib/typescript/src/websocket/index.d.ts.map +1 -0
  79. package/lib/typescript/src/websocket/protocol.d.ts +64 -0
  80. package/lib/typescript/src/websocket/protocol.d.ts.map +1 -0
  81. package/lib/typescript/src/websocket/serial-device-bridge.d.ts +32 -0
  82. package/lib/typescript/src/websocket/serial-device-bridge.d.ts.map +1 -0
  83. package/package.json +21 -3
  84. package/src/UsbSerial.ts +1 -1
  85. package/src/WebSerial.ts +134 -35
  86. package/src/index.ts +4 -1
  87. package/src/lib/event-target.ts +12 -0
  88. package/src/lib/web-streams.ts +43 -0
  89. package/src/testing/device-fixture.ts +150 -0
  90. package/src/testing/expose.ts +147 -0
  91. package/src/testing/harness.ts +124 -0
  92. package/src/testing/{virtual-serial.ts → in-memory-serial-transport.ts} +95 -56
  93. package/src/testing/index.ts +69 -21
  94. package/src/testing/install-in-memory-serial-transport.ts +65 -0
  95. package/src/testing/serial-client.ts +313 -0
  96. package/src/testing/{serial-device.ts → simulated-device.ts} +23 -23
  97. package/src/testing/test-suite.ts +186 -0
  98. package/src/transport.ts +3 -3
  99. package/src/websocket/WebSocketSerialTransport.ts +796 -0
  100. package/src/websocket/bridge.ts +299 -0
  101. package/src/websocket/index.ts +38 -0
  102. package/src/websocket/protocol.ts +101 -0
  103. package/src/websocket/serial-device-bridge.ts +160 -0
  104. package/lib/commonjs/testing/install.js +0 -54
  105. package/lib/commonjs/testing/install.js.map +0 -1
  106. package/lib/commonjs/testing/serial-device.js.map +0 -1
  107. package/lib/commonjs/testing/virtual-serial.js.map +0 -1
  108. package/lib/typescript/src/testing/install.d.ts +0 -25
  109. package/lib/typescript/src/testing/install.d.ts.map +0 -1
  110. package/lib/typescript/src/testing/serial-device.d.ts.map +0 -1
  111. package/lib/typescript/src/testing/virtual-serial.d.ts.map +0 -1
  112. package/src/testing/install.ts +0 -65
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `exposeSimulatedDevice` — run a {@link SimulatedDevice} simulator behind a WebSocket
3
+ * server so a real app (on a device/emulator) can connect to it with
4
+ * `new Serial(new WebSocketSerialTransport(url))` and drive the *same* simulated
5
+ * peripheral your Jest tests drive. The test process keeps the
6
+ * {@link DeviceHandle} handle, so it can inject frames / move the GPS and
7
+ * `await whenOpened()` for the app to connect — turning an in-memory device
8
+ * suite into an on-device E2E without changing the device code.
9
+ *
10
+ * `ws` is an *optional* dependency: it is loaded lazily (and only in Node), via
11
+ * an indirect require so app bundlers never pull it into a mobile bundle. Pass
12
+ * `options.WebSocketServer` to inject your own (e.g. `import {WebSocketServer}
13
+ * from 'ws'`) and skip the lazy load entirely.
14
+ *
15
+ * @example
16
+ * import {WebSocketServer} from 'ws';
17
+ * const ex = exposeSimulatedDevice(new WMBusGateway('iU891A-XL'), {
18
+ * port: 8090,
19
+ * WebSocketServer,
20
+ * });
21
+ * // …app connects to ex.url…
22
+ * await ex.whenOpened();
23
+ * ex.simulatedDevice.addMeter(meter);
24
+ * meter.sendTelegram(); // the app receives the 0x20 telegram event
25
+ * await ex.close();
26
+ */
27
+
28
+ import {
29
+ attachBridge,
30
+ portInfoFromDevice,
31
+ SimulatedDeviceToSerialLike,
32
+ type WsLike,
33
+ } from '../websocket';
34
+ import type {DeviceOptions} from './in-memory-serial-transport';
35
+ import {
36
+ type DeviceHandle,
37
+ InMemorySerialTransport,
38
+ } from './in-memory-serial-transport';
39
+ import type {
40
+ SimulatedDevice,
41
+ SimulatedDeviceOpenOptions,
42
+ } from './simulated-device';
43
+
44
+ /** The `ws` WebSocketServer surface this helper uses. */
45
+ export type WebSocketServerLike = {
46
+ on(event: 'connection', listener: (socket: WsLike) => void): void;
47
+ close(cb?: () => void): void;
48
+ };
49
+
50
+ /** A `ws`-compatible `WebSocketServer` constructor. */
51
+ export type WebSocketServerCtor = new (options: {
52
+ port: number;
53
+ host?: string;
54
+ }) => WebSocketServerLike;
55
+
56
+ export type ExposeSimulatedDeviceOptions = {
57
+ /** TCP port for the WebSocket server. */
58
+ port: number;
59
+ /** Listen address. Defaults to `localhost`. Use `0.0.0.0` for an emulator. */
60
+ host?: string;
61
+ /** Inject a `ws`-compatible `WebSocketServer` (skips the lazy `require('ws')`). */
62
+ WebSocketServer?: WebSocketServerCtor;
63
+ /** Forward device→client data before the app sends startReading. Default true. */
64
+ readingByDefault?: boolean;
65
+ /** Diagnostics logger. */
66
+ log?: (message: string) => void;
67
+ /** Transport-side device options (hasPermission defaults to true). */
68
+ device?: DeviceOptions;
69
+ };
70
+
71
+ export type ExposedDevice<D extends SimulatedDevice = SimulatedDevice> = {
72
+ /** The URL the app connects to, e.g. `ws://localhost:8090`. */
73
+ url: string;
74
+ transport: InMemorySerialTransport;
75
+ /** The transport-side handle: push/emitError/whenOpened/whenClosed/… */
76
+ device: DeviceHandle;
77
+ /** The concrete device simulator, typed (drive it from the test). */
78
+ simulatedDevice: D;
79
+ /** Resolve when the app opens the port (now if already open). */
80
+ whenOpened(): Promise<SimulatedDeviceOpenOptions>;
81
+ /** Resolve when the app closes the port (now if not open). */
82
+ whenClosed(): Promise<void>;
83
+ /** Stop the WebSocket server. */
84
+ close(): Promise<void>;
85
+ };
86
+
87
+ /** Resolve a `ws` WebSocketServer lazily, in Node only, invisibly to bundlers. */
88
+ function loadWebSocketServer(): WebSocketServerCtor {
89
+ const specifier = 'ws';
90
+ const nodeRequire =
91
+ // @ts-ignore
92
+ typeof module !== 'undefined' &&
93
+ // @ts-ignore
94
+ typeof (module as {require?: unknown}).require === 'function'
95
+ ? // @ts-ignore
96
+ (module as {require: (id: string) => unknown}).require.bind(module)
97
+ : /* istanbul ignore next */ undefined;
98
+ /* istanbul ignore next — only reachable in non-Node bundled environments */
99
+ if (!nodeRequire) {
100
+ throw new Error(
101
+ "exposeSimulatedDevice could not load 'ws'. Pass options.WebSocketServer, " +
102
+ 'or run it in a Node process with the optional `ws` package installed.',
103
+ );
104
+ }
105
+ const ws = nodeRequire(specifier) as {
106
+ WebSocketServer?: WebSocketServerCtor;
107
+ Server?: WebSocketServerCtor;
108
+ };
109
+ const Ctor = ws.WebSocketServer ?? ws.Server;
110
+ if (!Ctor) {
111
+ throw new Error("the 'ws' package did not export a WebSocketServer.");
112
+ }
113
+ return Ctor;
114
+ }
115
+
116
+ export function exposeSimulatedDevice<D extends SimulatedDevice>(
117
+ simulatedDevice: D,
118
+ options: ExposeSimulatedDeviceOptions,
119
+ ): ExposedDevice<D> {
120
+ const transport = new InMemorySerialTransport();
121
+ const device = transport.addDevice(simulatedDevice, {
122
+ hasPermission: true,
123
+ ...options.device,
124
+ });
125
+
126
+ const Ctor = options.WebSocketServer ?? loadWebSocketServer();
127
+ const server = new Ctor({port: options.port, host: options.host});
128
+ server.on('connection', socket => {
129
+ const serial = SimulatedDeviceToSerialLike(transport, device);
130
+ attachBridge(serial, socket, {
131
+ portInfo: portInfoFromDevice(device),
132
+ readingByDefault: options.readingByDefault,
133
+ log: options.log,
134
+ });
135
+ });
136
+
137
+ const host = options.host ?? 'localhost';
138
+ return {
139
+ url: `ws://${host}:${options.port}`,
140
+ transport,
141
+ device,
142
+ simulatedDevice,
143
+ whenOpened: () => device.whenOpened(),
144
+ whenClosed: () => device.whenClosed(),
145
+ close: () => new Promise<void>(resolve => server.close(() => resolve())),
146
+ };
147
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tiny, dependency-free test helpers for driving serial devices — shared by the
3
+ * library's own conformance suite, the {@link SerialClient}, and consumers' app
4
+ * tests. Free of `jest` and `react-native`, so they run under any test runner
5
+ * and on a real device (e.g. an on-device Self-Test screen).
6
+ */
7
+
8
+ /** Throw `message` if `condition` is falsy. */
9
+ export function assert(condition: unknown, message: string): asserts condition {
10
+ if (!condition) throw new Error(message);
11
+ }
12
+
13
+ /** Throw if `actual !== expected`, including both values in the message. */
14
+ export function assertEqual<T>(actual: T, expected: T, message: string): void {
15
+ if (actual !== expected) {
16
+ throw new Error(
17
+ `${message} (expected ${String(expected)}, got ${String(actual)})`,
18
+ );
19
+ }
20
+ }
21
+
22
+ /** Byte-for-byte equality of two sequences. */
23
+ export function bytesEqual(
24
+ a: ArrayLike<number>,
25
+ b: ArrayLike<number>,
26
+ ): boolean {
27
+ if (a.length !== b.length) return false;
28
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
29
+ return true;
30
+ }
31
+
32
+ /** What {@link assertRejects} may additionally check about the thrown error. */
33
+ export type RejectionExpectation = {
34
+ name?: string;
35
+ type?: new (...args: never[]) => Error;
36
+ message?: string | RegExp;
37
+ };
38
+
39
+ /** Assert `fn()` rejects (optionally matching the error name/type/message). */
40
+ export async function assertRejects(
41
+ fn: () => Promise<unknown>,
42
+ message: string,
43
+ expected?: RejectionExpectation,
44
+ ): Promise<void> {
45
+ try {
46
+ await fn();
47
+ } catch (e) {
48
+ const err = e as Error;
49
+ if (expected?.name && err.name !== expected.name) {
50
+ throw new Error(
51
+ `${message}: expected error "${expected.name}" but got "${err.name}"`,
52
+ );
53
+ }
54
+ if (expected?.type && !(err instanceof expected.type)) {
55
+ throw new Error(`${message}: expected a ${expected.type.name}`);
56
+ }
57
+ if (expected?.message !== undefined) {
58
+ const ok =
59
+ expected.message instanceof RegExp
60
+ ? expected.message.test(err.message)
61
+ : err.message.includes(expected.message);
62
+ if (!ok) {
63
+ throw new Error(
64
+ `${message}: expected the error message to match ${String(expected.message)}`,
65
+ );
66
+ }
67
+ }
68
+ return;
69
+ }
70
+ throw new Error(`${message}: expected a rejection but none occurred`);
71
+ }
72
+
73
+ /** Normalise any thrown value into a `"Name: message"` string. */
74
+ export function errorMessage(e: unknown): string {
75
+ return e instanceof Error ? `${e.name}: ${e.message}` : String(e);
76
+ }
77
+
78
+ /** Reject if `promise` doesn't settle within `ms`; `label` names it on timeout. */
79
+ export function withTimeout<T>(
80
+ promise: Promise<T>,
81
+ ms: number,
82
+ label: string,
83
+ ): Promise<T> {
84
+ return new Promise<T>((resolve, reject) => {
85
+ const timer = setTimeout(
86
+ () => reject(new Error(`timed out: ${label}`)),
87
+ ms,
88
+ );
89
+ promise.then(
90
+ v => {
91
+ clearTimeout(timer);
92
+ resolve(v);
93
+ },
94
+ e => {
95
+ clearTimeout(timer);
96
+ reject(e);
97
+ },
98
+ );
99
+ });
100
+ }
101
+
102
+ /** The slice of `ReadableStreamDefaultReader` the helpers need. */
103
+ export type ByteReader = {
104
+ read(): Promise<{done: boolean; value?: Uint8Array}>;
105
+ };
106
+
107
+ /** Read exactly `count` bytes from a reader (accumulating chunks), with a timeout. */
108
+ export async function readBytes(
109
+ reader: ByteReader,
110
+ count: number,
111
+ timeoutMs = 2000,
112
+ ): Promise<number[]> {
113
+ const out: number[] = [];
114
+ while (out.length < count) {
115
+ const {done, value} = await withTimeout(
116
+ reader.read(),
117
+ timeoutMs,
118
+ `reading ${count} bytes (got ${out.length})`,
119
+ );
120
+ if (done) break;
121
+ if (value) out.push(...value);
122
+ }
123
+ return out;
124
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * VirtualSerialTransport — an in-memory {@link SerialTransport} for tests and
2
+ * InMemorySerialTransport — an in-memory {@link SerialTransport} for tests and
3
3
  * on-device demos.
4
4
  *
5
5
  * It implements the exact same interface the production `UsbSerialModule` does,
@@ -13,8 +13,8 @@
13
13
  * Inject it via `new Serial(transport)` or globally with `setUsbSerial(transport)`.
14
14
  *
15
15
  * @example
16
- * const transport = new VirtualSerialTransport();
17
- * transport.addDevice(new EchoDevice(), {hasPermission: true});
16
+ * const transport = new InMemorySerialTransport();
17
+ * transport.addDevice(new LoopbackDevice(), {hasPermission: true});
18
18
  * const serial = new Serial(transport);
19
19
  * const [port] = await serial.getPorts();
20
20
  * await port.open({baudRate: 115200});
@@ -36,13 +36,13 @@ import type {
36
36
  } from '../transport';
37
37
  import {DEFAULT_OPEN_OPTIONS} from '../transport';
38
38
  import type {
39
- SerialDevice,
40
- SerialDeviceHost,
41
39
  SerialInputSignals,
42
- } from './serial-device';
40
+ SimulatedDevice,
41
+ SimulatedDeviceHost,
42
+ } from './simulated-device';
43
43
 
44
- /** Transport-side knobs when registering a {@link SerialDevice}. */
45
- export type VirtualDeviceOptions = {
44
+ /** Transport-side knobs when registering a {@link SimulatedDevice}. */
45
+ export type DeviceOptions = {
46
46
  /** Whether the app already holds USB permission. Defaults to false. */
47
47
  hasPermission?: boolean;
48
48
  /** Defaults to 0. A USB device may expose several ports. */
@@ -63,9 +63,9 @@ export type VirtualDeviceOptions = {
63
63
  flowControlThreshold?: number;
64
64
  };
65
65
 
66
- export type VirtualSerialOptions = {
66
+ export type InMemorySerialTransportOptions = {
67
67
  /** Devices to register on construction (same as calling addDevice). */
68
- devices?: SerialDevice[];
68
+ devices?: SimulatedDevice[];
69
69
  /**
70
70
  * Delay (ms) applied to async operations and to inbound data delivery.
71
71
  * 0 (default) resolves on a microtask — deterministic for Jest. A small
@@ -113,11 +113,11 @@ function mapInputSignals(s: SerialInputSignals): Partial<InputSignals> {
113
113
  }
114
114
 
115
115
  /**
116
- * A simulated USB-serial device. Returned by {@link VirtualSerialTransport.addDevice}.
116
+ * A simulated USB-serial device. Returned by {@link InMemorySerialTransport.addDevice}.
117
117
  * The mutable fields and the helper methods let a test or demo drive the device
118
118
  * the way physical hardware (and a human plugging cables) otherwise would.
119
119
  */
120
- export class VirtualDevice {
120
+ export class DeviceHandle {
121
121
  readonly usbVendorId: number;
122
122
  readonly usbProductId: number;
123
123
  readonly portNumber: number;
@@ -130,7 +130,7 @@ export class VirtualDevice {
130
130
  isOpen = false;
131
131
  reading = false;
132
132
  /** The hosted behaviour. */
133
- readonly serialDevice: SerialDevice;
133
+ readonly simulatedDevice: SimulatedDevice;
134
134
  loopbackSignals: boolean;
135
135
  flowControl: FlowControl = 'NONE';
136
136
  flowControlThreshold: number;
@@ -159,17 +159,17 @@ export class VirtualDevice {
159
159
  readonly written: number[][] = [];
160
160
 
161
161
  readonly #fails = new Set<FailableOp>();
162
- readonly #transport: VirtualSerialTransport;
162
+ readonly #transport: InMemorySerialTransport;
163
163
 
164
164
  constructor(
165
- transport: VirtualSerialTransport,
165
+ transport: InMemorySerialTransport,
166
166
  deviceId: number,
167
- device: SerialDevice,
168
- options: VirtualDeviceOptions,
167
+ device: SimulatedDevice,
168
+ options: DeviceOptions,
169
169
  ) {
170
170
  this.#transport = transport;
171
171
  this.deviceId = deviceId;
172
- this.serialDevice = device;
172
+ this.simulatedDevice = device;
173
173
  this.usbVendorId = device.usbVendorId;
174
174
  this.usbProductId = device.usbProductId;
175
175
  this.portNumber = options.portNumber ?? 0;
@@ -241,15 +241,49 @@ export class VirtualDevice {
241
241
  loseDevice(): void {
242
242
  this.#transport.loseDevice(this);
243
243
  }
244
+
245
+ readonly #openWaiters = new Set<(options: Required<OpenOptions>) => void>();
246
+ readonly #closeWaiters = new Set<() => void>();
247
+
248
+ /**
249
+ * Resolve when the host opens this port (immediately if already open). Lets a
250
+ * test `await` the app connecting before driving the device.
251
+ */
252
+ whenOpened(): Promise<Required<OpenOptions>> {
253
+ if (this.isOpen && this.openOptions) {
254
+ return Promise.resolve(this.openOptions);
255
+ }
256
+ return new Promise(resolve => this.#openWaiters.add(resolve));
257
+ }
258
+
259
+ /** Resolve when the host closes this port (immediately if not open). */
260
+ whenClosed(): Promise<void> {
261
+ if (!this.isOpen) {
262
+ return Promise.resolve();
263
+ }
264
+ return new Promise(resolve => this.#closeWaiters.add(resolve));
265
+ }
266
+
267
+ /** @internal The transport calls this right after the port opens. */
268
+ _notifyOpen(options: Required<OpenOptions>): void {
269
+ for (const waiter of [...this.#openWaiters]) waiter(options);
270
+ this.#openWaiters.clear();
271
+ }
272
+
273
+ /** @internal The transport calls this right after the port closes/detaches. */
274
+ _notifyClose(): void {
275
+ for (const waiter of [...this.#closeWaiters]) waiter();
276
+ this.#closeWaiters.clear();
277
+ }
244
278
  }
245
279
 
246
280
  type Listener<E> = (event: E) => void;
247
281
 
248
282
  /**
249
- * In-memory transport backing one or more {@link VirtualDevice}s.
283
+ * In-memory transport backing one or more {@link DeviceHandle}s.
250
284
  */
251
- export class VirtualSerialTransport implements SerialTransport {
252
- readonly #devices: VirtualDevice[] = [];
285
+ export class InMemorySerialTransport implements SerialTransport {
286
+ readonly #devices: DeviceHandle[] = [];
253
287
  readonly #latencyMs: number;
254
288
  readonly #autoGrant: boolean;
255
289
  readonly #chunkSize: number;
@@ -262,12 +296,12 @@ export class VirtualSerialTransport implements SerialTransport {
262
296
 
263
297
  /** Scripts the next showPortPicker() outcome. */
264
298
  #pendingPick:
265
- | VirtualDevice
266
- | ((d: VirtualDevice) => boolean)
299
+ | DeviceHandle
300
+ | ((d: DeviceHandle) => boolean)
267
301
  | 'reject'
268
302
  | null = null;
269
303
 
270
- constructor(options: VirtualSerialOptions = {}) {
304
+ constructor(options: InMemorySerialTransportOptions = {}) {
271
305
  this.#latencyMs = options.latencyMs ?? 0;
272
306
  this.#autoGrant = options.autoGrantPermission ?? true;
273
307
  this.#chunkSize = options.chunkSize ?? 0;
@@ -277,32 +311,32 @@ export class VirtualSerialTransport implements SerialTransport {
277
311
  // ── Device management ──────────────────────────────────────────────────────
278
312
 
279
313
  /** All devices known to the transport (attached or not). */
280
- get devices(): readonly VirtualDevice[] {
314
+ get devices(): readonly DeviceHandle[] {
281
315
  return this.#devices;
282
316
  }
283
317
 
284
318
  /**
285
- * Register a {@link SerialDevice}. It starts attached but does not fire
319
+ * Register a {@link SimulatedDevice}. It starts attached but does not fire
286
320
  * "connect"; its identity (usbVendorId/usbProductId/serialNumber) is read from
287
321
  * the device, and `options` carries the transport-side knobs.
288
322
  */
289
323
  addDevice(
290
- serialDevice: SerialDevice,
291
- options: VirtualDeviceOptions = {},
292
- ): VirtualDevice {
293
- const device = new VirtualDevice(
324
+ simulatedDevice: SimulatedDevice,
325
+ options: DeviceOptions = {},
326
+ ): DeviceHandle {
327
+ const device = new DeviceHandle(
294
328
  this,
295
329
  this.#nextDeviceId++,
296
- serialDevice,
330
+ simulatedDevice,
297
331
  options,
298
332
  );
299
- serialDevice._bind(this.#hostFor(device));
333
+ simulatedDevice._bind(this.#hostFor(device));
300
334
  this.#devices.push(device);
301
335
  return device;
302
336
  }
303
337
 
304
- /** The handle a hosted {@link SerialDevice} uses to talk back to the host. */
305
- #hostFor(device: VirtualDevice): SerialDeviceHost {
338
+ /** The handle a hosted {@link SimulatedDevice} uses to talk back to the host. */
339
+ #hostFor(device: DeviceHandle): SimulatedDeviceHost {
306
340
  return {
307
341
  get deviceId() {
308
342
  return device.deviceId;
@@ -328,23 +362,23 @@ export class VirtualSerialTransport implements SerialTransport {
328
362
  const result = fn();
329
363
  if (result && typeof (result as Promise<void>).then === 'function') {
330
364
  (result as Promise<void>).catch((err: unknown) => {
331
- console.error('SerialDevice hook rejected:', err);
365
+ console.error('SimulatedDevice hook rejected:', err);
332
366
  });
333
367
  }
334
368
  } catch (err) {
335
- console.error('SerialDevice hook threw:', err);
369
+ console.error('SimulatedDevice hook threw:', err);
336
370
  }
337
371
  }
338
372
 
339
373
  /** Remove a device entirely; detaches it first if attached. */
340
- removeDevice(device: VirtualDevice): void {
374
+ removeDevice(device: DeviceHandle): void {
341
375
  if (device.attached) this.detach(device);
342
376
  const i = this.#devices.indexOf(device);
343
377
  if (i >= 0) this.#devices.splice(i, 1);
344
378
  }
345
379
 
346
380
  /** (Re)attach a device, assigning it a fresh deviceId, and fire "connect". */
347
- attach(device: VirtualDevice): void {
381
+ attach(device: DeviceHandle): void {
348
382
  device.deviceId = this.#nextDeviceId++;
349
383
  device.attached = true;
350
384
  this.#emit(this.#connectListeners, {
@@ -355,28 +389,29 @@ export class VirtualSerialTransport implements SerialTransport {
355
389
  }
356
390
 
357
391
  /** Detach a device and fire "disconnect"; any open port becomes closed. */
358
- detach(device: VirtualDevice): void {
392
+ detach(device: DeviceHandle, lost = false): void {
359
393
  const {deviceId, usbVendorId, usbProductId} = device;
394
+ const wasOpen = device.isOpen;
360
395
  device.attached = false;
361
396
  device.isOpen = false;
362
397
  device.reading = false;
398
+ if (wasOpen) device._notifyClose();
363
399
  this.#emit(this.#disconnectListeners, {
364
400
  deviceId,
365
401
  usbVendorId,
366
402
  usbProductId,
403
+ lost,
367
404
  });
368
405
  }
369
406
 
370
407
  /** Simulate an unplug while open: error the stream first, then disconnect. */
371
- loseDevice(device: VirtualDevice): void {
408
+ loseDevice(device: DeviceHandle): void {
372
409
  if (device.isOpen) this._error(device, 'Device disconnected');
373
- this.detach(device);
410
+ this.detach(device, true);
374
411
  }
375
412
 
376
413
  /** Script the next showPortPicker() resolution (a device or a predicate). */
377
- selectNextPort(
378
- target: VirtualDevice | ((d: VirtualDevice) => boolean),
379
- ): void {
414
+ selectNextPort(target: DeviceHandle | ((d: DeviceHandle) => boolean)): void {
380
415
  this.#pendingPick = target;
381
416
  }
382
417
 
@@ -385,10 +420,10 @@ export class VirtualSerialTransport implements SerialTransport {
385
420
  this.#pendingPick = 'reject';
386
421
  }
387
422
 
388
- // ── Internal event helpers (called by VirtualDevice) ───────────────────────
423
+ // ── Internal event helpers (called by DeviceHandle) ──────────────────────────────
389
424
 
390
425
  /** @internal deliver inbound bytes to the host's readable stream. */
391
- _deliver(device: VirtualDevice, data: number[]): void {
426
+ _deliver(device: DeviceHandle, data: number[]): void {
392
427
  if (!device.attached || !device.isOpen || !device.reading) return;
393
428
 
394
429
  if (device.overrunLimit != null) {
@@ -409,7 +444,7 @@ export class VirtualSerialTransport implements SerialTransport {
409
444
  }
410
445
 
411
446
  /** @internal raise a read error for a device's open port. */
412
- _error(device: VirtualDevice, message: string, name?: string): void {
447
+ _error(device: DeviceHandle, message: string, name?: string): void {
413
448
  const event: ErrorEvent = {
414
449
  deviceId: device.deviceId,
415
450
  portNumber: device.portNumber,
@@ -420,7 +455,7 @@ export class VirtualSerialTransport implements SerialTransport {
420
455
  }
421
456
 
422
457
  /** Deliver `data` as one or more onData events, honouring `chunkSize`. */
423
- #emitData(device: VirtualDevice, data: number[]): void {
458
+ #emitData(device: DeviceHandle, data: number[]): void {
424
459
  const emitOne = (slice: number[]) => {
425
460
  const event: DataEvent = {
426
461
  deviceId: device.deviceId,
@@ -461,7 +496,7 @@ export class VirtualSerialTransport implements SerialTransport {
461
496
  d => d.attached && this.#matchesFilters(d, filter),
462
497
  );
463
498
 
464
- let chosen: VirtualDevice | undefined;
499
+ let chosen: DeviceHandle | undefined;
465
500
  if (typeof pick === 'function') {
466
501
  chosen = candidates.find(pick);
467
502
  } else if (pick) {
@@ -496,7 +531,8 @@ export class VirtualSerialTransport implements SerialTransport {
496
531
  device.isOpen = true;
497
532
  device.openOptions = {...DEFAULT_OPEN_OPTIONS, ...options};
498
533
  device._hwWritten = 0;
499
- this.#invokeHook(() => device.serialDevice.onOpen(device.openOptions!));
534
+ this.#invokeHook(() => device.simulatedDevice.onOpen(device.openOptions!));
535
+ device._notifyOpen(device.openOptions);
500
536
  return this.#resolve();
501
537
  }
502
538
 
@@ -508,7 +544,8 @@ export class VirtualSerialTransport implements SerialTransport {
508
544
  if (device) {
509
545
  device.isOpen = false;
510
546
  device.reading = false;
511
- this.#invokeHook(() => device.serialDevice.onClose());
547
+ this.#invokeHook(() => device.simulatedDevice.onClose());
548
+ device._notifyClose();
512
549
  }
513
550
  return this.#resolve();
514
551
  }
@@ -535,7 +572,9 @@ export class VirtualSerialTransport implements SerialTransport {
535
572
  const bytes = data.map(toByte);
536
573
  device.written.push(bytes);
537
574
  if (device.flowControl === 'RTS_CTS') device._hwWritten += bytes.length;
538
- this.#invokeHook(() => device.serialDevice.onData(Uint8Array.from(bytes)));
575
+ this.#invokeHook(() =>
576
+ device.simulatedDevice.onData(Uint8Array.from(bytes)),
577
+ );
539
578
  return this.#resolve();
540
579
  }
541
580
 
@@ -723,7 +762,7 @@ export class VirtualSerialTransport implements SerialTransport {
723
762
  }
724
763
  }
725
764
  this.#invokeHook(() =>
726
- device.serialDevice.onHostSignals({
765
+ device.simulatedDevice.onHostSignals({
727
766
  dataTerminalReady: device.output.dtr,
728
767
  requestToSend: device.output.rts,
729
768
  break: device.output.brk,
@@ -733,7 +772,7 @@ export class VirtualSerialTransport implements SerialTransport {
733
772
  return this.#resolve();
734
773
  }
735
774
 
736
- #toPortId = (d: VirtualDevice): PortId => ({
775
+ #toPortId = (d: DeviceHandle): PortId => ({
737
776
  deviceId: d.deviceId,
738
777
  portNumber: d.portNumber,
739
778
  usbVendorId: d.usbVendorId,
@@ -742,7 +781,7 @@ export class VirtualSerialTransport implements SerialTransport {
742
781
  });
743
782
 
744
783
  #matchesFilters(
745
- device: VirtualDevice,
784
+ device: DeviceHandle,
746
785
  filters: ReadonlyArray<PortFilter>,
747
786
  ): boolean {
748
787
  if (!filters || filters.length === 0) return true;
@@ -760,7 +799,7 @@ export class VirtualSerialTransport implements SerialTransport {
760
799
  });
761
800
  }
762
801
 
763
- #find(deviceId: number, portNumber?: number): VirtualDevice | undefined {
802
+ #find(deviceId: number, portNumber?: number): DeviceHandle | undefined {
764
803
  return this.#devices.find(
765
804
  d =>
766
805
  d.attached &&