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.
- package/README.md +188 -117
- package/TESTING.md +417 -176
- package/android/build.gradle +14 -0
- package/android/src/main/java/dev/webserialapi/NativeUsbSerialModule.java +74 -11
- package/android/src/main/java/dev/webserialapi/PortPickerActivity.java +61 -59
- package/bin/expose-serial.js +205 -0
- package/lib/commonjs/UsbSerial.js +1 -1
- package/lib/commonjs/WebSerial.js +110 -26
- package/lib/commonjs/WebSerial.js.map +1 -1
- package/lib/commonjs/index.js +2 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/lib/event-target.js +3 -1
- package/lib/commonjs/lib/event-target.js.map +1 -1
- package/lib/commonjs/lib/web-streams.js +42 -0
- package/lib/commonjs/lib/web-streams.js.map +1 -0
- package/lib/commonjs/testing/device-fixture.js +70 -0
- package/lib/commonjs/testing/device-fixture.js.map +1 -0
- package/lib/commonjs/testing/expose.js +91 -0
- package/lib/commonjs/testing/expose.js.map +1 -0
- package/lib/commonjs/testing/harness.js +98 -0
- package/lib/commonjs/testing/harness.js.map +1 -0
- package/lib/commonjs/testing/{virtual-serial.js → in-memory-serial-transport.js} +66 -28
- package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/index.js +100 -17
- package/lib/commonjs/testing/index.js.map +1 -1
- package/lib/commonjs/testing/install-in-memory-serial-transport.js +54 -0
- package/lib/commonjs/testing/install-in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/serial-client.js +277 -0
- package/lib/commonjs/testing/serial-client.js.map +1 -0
- package/lib/commonjs/testing/{serial-device.js → simulated-device.js} +17 -17
- package/lib/commonjs/testing/simulated-device.js.map +1 -0
- package/lib/commonjs/testing/test-suite.js +142 -0
- package/lib/commonjs/testing/test-suite.js.map +1 -0
- package/lib/commonjs/transport.js +3 -3
- package/lib/commonjs/websocket/WebSocketSerialTransport.js +659 -0
- package/lib/commonjs/websocket/WebSocketSerialTransport.js.map +1 -0
- package/lib/commonjs/websocket/bridge.js +234 -0
- package/lib/commonjs/websocket/bridge.js.map +1 -0
- package/lib/commonjs/websocket/index.js +33 -0
- package/lib/commonjs/websocket/index.js.map +1 -0
- package/lib/commonjs/websocket/protocol.js +55 -0
- package/lib/commonjs/websocket/protocol.js.map +1 -0
- package/lib/commonjs/websocket/serial-device-bridge.js +130 -0
- package/lib/commonjs/websocket/serial-device-bridge.js.map +1 -0
- package/lib/typescript/src/UsbSerial.d.ts +1 -1
- package/lib/typescript/src/WebSerial.d.ts +7 -7
- package/lib/typescript/src/WebSerial.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/lib/event-target.d.ts +2 -0
- package/lib/typescript/src/lib/event-target.d.ts.map +1 -1
- package/lib/typescript/src/lib/web-streams.d.ts +9 -0
- package/lib/typescript/src/lib/web-streams.d.ts.map +1 -0
- package/lib/typescript/src/testing/device-fixture.d.ts +70 -0
- package/lib/typescript/src/testing/device-fixture.d.ts.map +1 -0
- package/lib/typescript/src/testing/expose.d.ts +71 -0
- package/lib/typescript/src/testing/expose.d.ts.map +1 -0
- package/lib/typescript/src/testing/harness.d.ts +34 -0
- package/lib/typescript/src/testing/harness.d.ts.map +1 -0
- package/lib/typescript/src/testing/{virtual-serial.d.ts → in-memory-serial-transport.d.ts} +37 -26
- package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/index.d.ts +18 -8
- package/lib/typescript/src/testing/index.d.ts.map +1 -1
- package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts +25 -0
- package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/serial-client.d.ts +62 -0
- package/lib/typescript/src/testing/serial-client.d.ts.map +1 -0
- package/lib/typescript/src/testing/{serial-device.d.ts → simulated-device.d.ts} +23 -23
- package/lib/typescript/src/testing/simulated-device.d.ts.map +1 -0
- package/lib/typescript/src/testing/test-suite.d.ts +75 -0
- package/lib/typescript/src/testing/test-suite.d.ts.map +1 -0
- package/lib/typescript/src/transport.d.ts +3 -3
- package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts +111 -0
- package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts.map +1 -0
- package/lib/typescript/src/websocket/bridge.d.ts +66 -0
- package/lib/typescript/src/websocket/bridge.d.ts.map +1 -0
- package/lib/typescript/src/websocket/index.d.ts +19 -0
- package/lib/typescript/src/websocket/index.d.ts.map +1 -0
- package/lib/typescript/src/websocket/protocol.d.ts +64 -0
- package/lib/typescript/src/websocket/protocol.d.ts.map +1 -0
- package/lib/typescript/src/websocket/serial-device-bridge.d.ts +32 -0
- package/lib/typescript/src/websocket/serial-device-bridge.d.ts.map +1 -0
- package/package.json +21 -3
- package/src/UsbSerial.ts +1 -1
- package/src/WebSerial.ts +134 -35
- package/src/index.ts +4 -1
- package/src/lib/event-target.ts +12 -0
- package/src/lib/web-streams.ts +43 -0
- package/src/testing/device-fixture.ts +150 -0
- package/src/testing/expose.ts +147 -0
- package/src/testing/harness.ts +124 -0
- package/src/testing/{virtual-serial.ts → in-memory-serial-transport.ts} +95 -56
- package/src/testing/index.ts +69 -21
- package/src/testing/install-in-memory-serial-transport.ts +65 -0
- package/src/testing/serial-client.ts +313 -0
- package/src/testing/{serial-device.ts → simulated-device.ts} +23 -23
- package/src/testing/test-suite.ts +186 -0
- package/src/transport.ts +3 -3
- package/src/websocket/WebSocketSerialTransport.ts +796 -0
- package/src/websocket/bridge.ts +299 -0
- package/src/websocket/index.ts +38 -0
- package/src/websocket/protocol.ts +101 -0
- package/src/websocket/serial-device-bridge.ts +160 -0
- package/lib/commonjs/testing/install.js +0 -54
- package/lib/commonjs/testing/install.js.map +0 -1
- package/lib/commonjs/testing/serial-device.js.map +0 -1
- package/lib/commonjs/testing/virtual-serial.js.map +0 -1
- package/lib/typescript/src/testing/install.d.ts +0 -25
- package/lib/typescript/src/testing/install.d.ts.map +0 -1
- package/lib/typescript/src/testing/serial-device.d.ts.map +0 -1
- package/lib/typescript/src/testing/virtual-serial.d.ts.map +0 -1
- 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
|
-
*
|
|
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
|
|
17
|
-
* transport.addDevice(new
|
|
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
|
-
|
|
40
|
+
SimulatedDevice,
|
|
41
|
+
SimulatedDeviceHost,
|
|
42
|
+
} from './simulated-device';
|
|
43
43
|
|
|
44
|
-
/** Transport-side knobs when registering a {@link
|
|
45
|
-
export type
|
|
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
|
|
66
|
+
export type InMemorySerialTransportOptions = {
|
|
67
67
|
/** Devices to register on construction (same as calling addDevice). */
|
|
68
|
-
devices?:
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
162
|
+
readonly #transport: InMemorySerialTransport;
|
|
163
163
|
|
|
164
164
|
constructor(
|
|
165
|
-
transport:
|
|
165
|
+
transport: InMemorySerialTransport,
|
|
166
166
|
deviceId: number,
|
|
167
|
-
device:
|
|
168
|
-
options:
|
|
167
|
+
device: SimulatedDevice,
|
|
168
|
+
options: DeviceOptions,
|
|
169
169
|
) {
|
|
170
170
|
this.#transport = transport;
|
|
171
171
|
this.deviceId = deviceId;
|
|
172
|
-
this.
|
|
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
|
|
283
|
+
* In-memory transport backing one or more {@link DeviceHandle}s.
|
|
250
284
|
*/
|
|
251
|
-
export class
|
|
252
|
-
readonly #devices:
|
|
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
|
-
|
|
|
266
|
-
| ((d:
|
|
299
|
+
| DeviceHandle
|
|
300
|
+
| ((d: DeviceHandle) => boolean)
|
|
267
301
|
| 'reject'
|
|
268
302
|
| null = null;
|
|
269
303
|
|
|
270
|
-
constructor(options:
|
|
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
|
|
314
|
+
get devices(): readonly DeviceHandle[] {
|
|
281
315
|
return this.#devices;
|
|
282
316
|
}
|
|
283
317
|
|
|
284
318
|
/**
|
|
285
|
-
* Register a {@link
|
|
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
|
-
|
|
291
|
-
options:
|
|
292
|
-
):
|
|
293
|
-
const device = new
|
|
324
|
+
simulatedDevice: SimulatedDevice,
|
|
325
|
+
options: DeviceOptions = {},
|
|
326
|
+
): DeviceHandle {
|
|
327
|
+
const device = new DeviceHandle(
|
|
294
328
|
this,
|
|
295
329
|
this.#nextDeviceId++,
|
|
296
|
-
|
|
330
|
+
simulatedDevice,
|
|
297
331
|
options,
|
|
298
332
|
);
|
|
299
|
-
|
|
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
|
|
305
|
-
#hostFor(device:
|
|
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('
|
|
365
|
+
console.error('SimulatedDevice hook rejected:', err);
|
|
332
366
|
});
|
|
333
367
|
}
|
|
334
368
|
} catch (err) {
|
|
335
|
-
console.error('
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
423
|
+
// ── Internal event helpers (called by DeviceHandle) ──────────────────────────────
|
|
389
424
|
|
|
390
425
|
/** @internal deliver inbound bytes to the host's readable stream. */
|
|
391
|
-
_deliver(device:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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(() =>
|
|
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.
|
|
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:
|
|
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:
|
|
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):
|
|
802
|
+
#find(deviceId: number, portNumber?: number): DeviceHandle | undefined {
|
|
764
803
|
return this.#devices.find(
|
|
765
804
|
d =>
|
|
766
805
|
d.attached &&
|