react-native-web-serial-api 0.0.3 → 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 +198 -104
- package/TESTING.md +542 -0
- package/android/build.gradle +16 -2
- 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 +58 -26
- package/lib/commonjs/UsbSerial.js.map +1 -1
- package/lib/commonjs/WebSerial.js +273 -77
- package/lib/commonjs/WebSerial.js.map +1 -1
- package/lib/commonjs/index.js +15 -3
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/lib/dom-exception.js +176 -0
- package/lib/commonjs/lib/dom-exception.js.map +1 -0
- package/lib/commonjs/lib/event-target.js +140 -0
- package/lib/commonjs/lib/event-target.js.map +1 -0
- package/lib/commonjs/lib/promise.js +23 -0
- package/lib/commonjs/lib/promise.js.map +1 -0
- 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/in-memory-serial-transport.js +653 -0
- package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/index.js +153 -0
- package/lib/commonjs/testing/index.js.map +1 -0
- 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/simulated-device.js +164 -0
- 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 +61 -0
- package/lib/commonjs/transport.js.map +1 -0
- 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 +24 -67
- package/lib/typescript/src/UsbSerial.d.ts.map +1 -1
- package/lib/typescript/src/WebSerial.d.ts +16 -7
- package/lib/typescript/src/WebSerial.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/lib/dom-exception.d.ts +100 -0
- package/lib/typescript/src/lib/dom-exception.d.ts.map +1 -0
- package/lib/typescript/src/lib/event-target.d.ts +55 -0
- package/lib/typescript/src/lib/event-target.d.ts.map +1 -0
- package/lib/typescript/src/lib/promise.d.ts +11 -0
- package/lib/typescript/src/lib/promise.d.ts.map +1 -0
- 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/in-memory-serial-transport.d.ts +216 -0
- package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/index.d.ts +33 -0
- package/lib/typescript/src/testing/index.d.ts.map +1 -0
- 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/simulated-device.d.ts +127 -0
- 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 +131 -0
- package/lib/typescript/src/transport.d.ts.map +1 -0
- 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 +57 -3
- package/src/UsbSerial.ts +65 -90
- package/src/WebSerial.ts +351 -113
- package/src/index.ts +6 -8
- package/src/lib/dom-exception.ts +129 -60
- package/src/lib/event-target.ts +58 -21
- package/src/lib/promise.ts +7 -7
- 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/in-memory-serial-transport.ts +840 -0
- package/src/testing/index.ts +90 -0
- package/src/testing/install-in-memory-serial-transport.ts +65 -0
- package/src/testing/serial-client.ts +313 -0
- package/src/testing/simulated-device.ts +193 -0
- package/src/testing/test-suite.ts +186 -0
- package/src/transport.ts +200 -0
- 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/babel.config.js +0 -3
- package/biome.json +0 -35
- package/example/.watchmanconfig +0 -1
- package/example/App.tsx +0 -71
- package/example/__tests__/App.test.tsx +0 -16
- package/example/__tests__/connectEvents.test.tsx +0 -81
- package/example/__tests__/getPorts.test.tsx +0 -140
- package/example/android/app/build.gradle +0 -120
- package/example/android/app/debug.keystore +0 -0
- package/example/android/app/proguard-rules.pro +0 -10
- package/example/android/app/src/debug/AndroidManifest.xml +0 -9
- package/example/android/app/src/main/AndroidManifest.xml +0 -38
- package/example/android/app/src/main/java/dev/uzlopak/MainActivity.kt +0 -22
- package/example/android/app/src/main/java/dev/uzlopak/MainApplication.kt +0 -41
- package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +0 -37
- package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/values/strings.xml +0 -3
- package/example/android/app/src/main/res/values/styles.xml +0 -9
- package/example/android/build.gradle +0 -22
- package/example/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/example/android/gradle/wrapper/gradle-wrapper.properties +0 -7
- package/example/android/gradle.properties +0 -47
- package/example/android/gradlew +0 -252
- package/example/android/gradlew.bat +0 -94
- package/example/android/settings.gradle +0 -6
- package/example/app.json +0 -4
- package/example/babel.config.js +0 -21
- package/example/biome.json +0 -47
- package/example/deploy.sh +0 -11
- package/example/index.html +0 -26
- package/example/index.js +0 -9
- package/example/index.web.js +0 -8
- package/example/jest.config.js +0 -12
- package/example/metro.config.js +0 -58
- package/example/package-lock.json +0 -14510
- package/example/package.json +0 -48
- package/example/react-native.config.js +0 -17
- package/example/src/components/AppBar.tsx +0 -73
- package/example/src/components/Menu.tsx +0 -90
- package/example/src/components/SingleChoiceDialog.tsx +0 -120
- package/example/src/screens/ConnectScreen.tsx +0 -195
- package/example/src/screens/DevicesScreen.tsx +0 -252
- package/example/src/screens/TerminalScreen.tsx +0 -572
- package/example/src/settings.ts +0 -43
- package/example/src/theme.ts +0 -19
- package/example/src/util/TextUtil.ts +0 -129
- package/example/tsconfig.json +0 -10
- package/example/vite.config.mjs +0 -55
- package/scripts/deploy-release.sh +0 -127
- package/tsconfig.build.json +0 -7
- package/tsconfig.json +0 -20
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing & on-device-demo entry point.
|
|
3
|
+
*
|
|
4
|
+
* Imported via the package subpath:
|
|
5
|
+
*
|
|
6
|
+
* import {InMemorySerialTransport, installInMemorySerialTransport}
|
|
7
|
+
* from 'react-native-web-serial-api/testing';
|
|
8
|
+
*
|
|
9
|
+
* These are reusable building blocks for *consumers'* tests and demos — an
|
|
10
|
+
* in-memory transport and an authorable `SimulatedDevice` peripheral model — so
|
|
11
|
+
* they ship with the package. The library's own spec-compliance suite is NOT
|
|
12
|
+
* here: it lives in `src/__tests__/conformance-suite.ts` (test-only, excluded
|
|
13
|
+
* from the build and the published package). None of this is in the main bundle.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type {SerialTransport} from '../transport';
|
|
17
|
+
// The transport override, re-exported here for convenience so a test or the
|
|
18
|
+
// example's demo mode can flip the global transport from one import.
|
|
19
|
+
export {getUsbSerial, resetUsbSerial, setUsbSerial} from '../UsbSerial';
|
|
20
|
+
// One-call fixture: mount a device sim + drive both sides + await connect.
|
|
21
|
+
export type {
|
|
22
|
+
DeviceFixtureOptions,
|
|
23
|
+
MountedDeviceFixture,
|
|
24
|
+
MountedDeviceFixtures,
|
|
25
|
+
} from './device-fixture';
|
|
26
|
+
export {createDeviceFixture} from './device-fixture';
|
|
27
|
+
// Expose a device simulator over a WebSocket so a real app/emulator can connect
|
|
28
|
+
// to it (same device suite in Jest and on-device). Lazy `ws`, Node-only.
|
|
29
|
+
export type {
|
|
30
|
+
ExposedDevice,
|
|
31
|
+
ExposeSimulatedDeviceOptions,
|
|
32
|
+
WebSocketServerCtor,
|
|
33
|
+
WebSocketServerLike,
|
|
34
|
+
} from './expose';
|
|
35
|
+
export {exposeSimulatedDevice} from './expose';
|
|
36
|
+
// Dependency-free assertion/stream helpers for writing serial tests.
|
|
37
|
+
export type {ByteReader, RejectionExpectation} from './harness';
|
|
38
|
+
export {
|
|
39
|
+
assert,
|
|
40
|
+
assertEqual,
|
|
41
|
+
assertRejects,
|
|
42
|
+
bytesEqual,
|
|
43
|
+
errorMessage,
|
|
44
|
+
readBytes,
|
|
45
|
+
withTimeout,
|
|
46
|
+
} from './harness';
|
|
47
|
+
export type {
|
|
48
|
+
DeviceOptions,
|
|
49
|
+
FailableOp,
|
|
50
|
+
InMemorySerialTransportOptions,
|
|
51
|
+
} from './in-memory-serial-transport';
|
|
52
|
+
export {
|
|
53
|
+
DeviceHandle,
|
|
54
|
+
InMemorySerialTransport,
|
|
55
|
+
} from './in-memory-serial-transport';
|
|
56
|
+
export type {
|
|
57
|
+
InstallInMemorySerialTransportOptions,
|
|
58
|
+
SimulatedTransportDevice,
|
|
59
|
+
} from './install-in-memory-serial-transport';
|
|
60
|
+
// Inject a mock device set into a running app (for on-device / emulator E2E).
|
|
61
|
+
export {installInMemorySerialTransport} from './install-in-memory-serial-transport';
|
|
62
|
+
// The fluent host-side client (reader/writer/readBytes/readUntil/readLine/…).
|
|
63
|
+
export type {ReadOptions, SerialClientOptions} from './serial-client';
|
|
64
|
+
export {
|
|
65
|
+
createSerialClient,
|
|
66
|
+
SerialClient,
|
|
67
|
+
} from './serial-client';
|
|
68
|
+
export type {
|
|
69
|
+
HostSignals,
|
|
70
|
+
SerialInputSignals,
|
|
71
|
+
SimulatedDeviceHost,
|
|
72
|
+
SimulatedDeviceIdentity,
|
|
73
|
+
SimulatedDeviceOpenOptions,
|
|
74
|
+
} from './simulated-device';
|
|
75
|
+
// Author a whole simulated peripheral by extending SimulatedDevice.
|
|
76
|
+
export {
|
|
77
|
+
LineBufferedDevice,
|
|
78
|
+
LoopbackDevice,
|
|
79
|
+
SimulatedDevice,
|
|
80
|
+
SinkDevice,
|
|
81
|
+
} from './simulated-device';
|
|
82
|
+
// Runtime-agnostic suite runner: one suite, run in Jest + on-device + compare.
|
|
83
|
+
export type {
|
|
84
|
+
RunTestSuiteOptions,
|
|
85
|
+
SerialTest,
|
|
86
|
+
SerialTestProgress,
|
|
87
|
+
SerialTestResult,
|
|
88
|
+
TestClient,
|
|
89
|
+
} from './test-suite';
|
|
90
|
+
export {compareTestResults, runTestSuite} from './test-suite';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* installInMemorySerialTransport — point the library at a simulated device set in one call.
|
|
3
|
+
*
|
|
4
|
+
* Built for E2E: call it once at app startup, behind your own env/build flag, to
|
|
5
|
+
* make `navigator.serial` / this library's `serial` talk to simulated
|
|
6
|
+
* {@link SimulatedDevice}s instead of real USB hardware while a test driver
|
|
7
|
+
* (Maestro, Detox, …) exercises the app on a device/emulator.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // index.js (debug/E2E build only)
|
|
11
|
+
* import {installInMemorySerialTransport, LoopbackDevice} from 'react-native-web-serial-api/testing';
|
|
12
|
+
* installInMemorySerialTransport({
|
|
13
|
+
* enabled: process.env.RNWS_SERIAL_MOCK === '1',
|
|
14
|
+
* devices: [new LoopbackDevice(), new MyThermometer()],
|
|
15
|
+
* });
|
|
16
|
+
*/
|
|
17
|
+
import {setUsbSerial} from '../UsbSerial';
|
|
18
|
+
import type {
|
|
19
|
+
DeviceOptions,
|
|
20
|
+
InMemorySerialTransportOptions,
|
|
21
|
+
} from './in-memory-serial-transport';
|
|
22
|
+
import {InMemorySerialTransport} from './in-memory-serial-transport';
|
|
23
|
+
import {SimulatedDevice} from './simulated-device';
|
|
24
|
+
|
|
25
|
+
/** A device to register: a SimulatedDevice, optionally with transport options. */
|
|
26
|
+
export type SimulatedTransportDevice =
|
|
27
|
+
| SimulatedDevice
|
|
28
|
+
| {device: SimulatedDevice; options?: DeviceOptions};
|
|
29
|
+
|
|
30
|
+
export type InstallInMemorySerialTransportOptions = {
|
|
31
|
+
/** The simulated devices to expose. */
|
|
32
|
+
devices: SimulatedTransportDevice[];
|
|
33
|
+
/** When false, no mock is installed and `null` is returned. Defaults to true. */
|
|
34
|
+
enabled?: boolean;
|
|
35
|
+
/** Transport-level options (latency, chunkSize, …). */
|
|
36
|
+
transport?: InMemorySerialTransportOptions;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build an {@link InMemorySerialTransport} from `devices` and install it globally
|
|
41
|
+
* via {@link setUsbSerial}. SimulatedDevices default to granted USB permission (so
|
|
42
|
+
* they show up immediately); pass the `{device, options}` form to override.
|
|
43
|
+
* Returns the transport (handy for driving devices in-test), or `null` when
|
|
44
|
+
* disabled.
|
|
45
|
+
*/
|
|
46
|
+
export function installInMemorySerialTransport(
|
|
47
|
+
options: InstallInMemorySerialTransportOptions,
|
|
48
|
+
): InMemorySerialTransport | null {
|
|
49
|
+
if (options.enabled === false) return null;
|
|
50
|
+
|
|
51
|
+
const transport = new InMemorySerialTransport(options.transport);
|
|
52
|
+
for (const entry of options.devices) {
|
|
53
|
+
if (entry instanceof SimulatedDevice) {
|
|
54
|
+
transport.addDevice(entry, {hasPermission: true});
|
|
55
|
+
} else {
|
|
56
|
+
transport.addDevice(entry.device, {
|
|
57
|
+
hasPermission: true,
|
|
58
|
+
...entry.options,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
setUsbSerial(transport);
|
|
64
|
+
return transport;
|
|
65
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fluent request/response client over a {@link SerialPort} — the host-side
|
|
3
|
+
* driver every serial test otherwise hand-rolls (reader + writer, a pending
|
|
4
|
+
* byte buffer, framed reads with timeouts). Works on ANY SerialPort: the
|
|
5
|
+
* in-memory {@link InMemorySerialTransport}, a real USB device, or a
|
|
6
|
+
* WebSocket-backed one — so the same test runs in Jest and on a device.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const {client} = await mountDeviceFixture(new MyDevice());
|
|
10
|
+
* await client.open({baudRate: 115200});
|
|
11
|
+
* await client.write('PING\n');
|
|
12
|
+
* expect(await client.readLine()).toBe('PONG');
|
|
13
|
+
* await client.close();
|
|
14
|
+
*/
|
|
15
|
+
import type {SerialOptions, SerialPort} from '../WebSerial';
|
|
16
|
+
import {toBytes} from './simulated-device';
|
|
17
|
+
|
|
18
|
+
export type ReadOptions = {
|
|
19
|
+
/** Per-call timeout in ms (overrides the client default). */
|
|
20
|
+
timeout?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SerialClientOptions = {
|
|
24
|
+
/** Default per-read timeout in ms. Default 2000. */
|
|
25
|
+
defaultTimeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function indexOfSubsequence(haystack: number[], needle: number[]): number {
|
|
29
|
+
if (needle.length === 0) return 0;
|
|
30
|
+
outer: for (let i = 0; i + needle.length <= haystack.length; i++) {
|
|
31
|
+
for (let j = 0; j < needle.length; j++) {
|
|
32
|
+
if (haystack[i + j] !== needle[j]) continue outer;
|
|
33
|
+
}
|
|
34
|
+
return i;
|
|
35
|
+
}
|
|
36
|
+
return -1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class SerialClient {
|
|
40
|
+
readonly #port: SerialPort;
|
|
41
|
+
readonly #defaultTimeout: number;
|
|
42
|
+
|
|
43
|
+
#reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
44
|
+
#writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
|
|
45
|
+
#open = false;
|
|
46
|
+
#done = false;
|
|
47
|
+
#closed = false;
|
|
48
|
+
/** Inbound bytes read by the pump but not yet consumed. */
|
|
49
|
+
readonly #pending: number[] = [];
|
|
50
|
+
/** Resolvers woken whenever #pending grows or the stream ends. */
|
|
51
|
+
#waiters: Array<() => void> = [];
|
|
52
|
+
|
|
53
|
+
constructor(port: SerialPort, options: SerialClientOptions = {}) {
|
|
54
|
+
this.#port = port;
|
|
55
|
+
this.#defaultTimeout = options.defaultTimeoutMs ?? 2000;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get port(): SerialPort {
|
|
59
|
+
return this.#port;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get isOpen(): boolean {
|
|
63
|
+
return this.#open;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True once the underlying stream has ended (device closed or lost). */
|
|
67
|
+
get ended(): boolean {
|
|
68
|
+
return this.#done;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Open the port (idempotent) and acquire reader + writer; start reading. */
|
|
72
|
+
async open(options: SerialOptions = {baudRate: 115200}): Promise<this> {
|
|
73
|
+
if (this.#open) return this;
|
|
74
|
+
await this.#port.open(options);
|
|
75
|
+
this.#reader = this.#port.readable!.getReader();
|
|
76
|
+
this.#writer = this.#port.writable!.getWriter();
|
|
77
|
+
this.#open = true;
|
|
78
|
+
this.#done = false;
|
|
79
|
+
this.#closed = false;
|
|
80
|
+
void this.#pump();
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Background reader: drains the stream into #pending and wakes waiters. */
|
|
85
|
+
async #pump(): Promise<void> {
|
|
86
|
+
const reader = this.#reader;
|
|
87
|
+
/* istanbul ignore next */
|
|
88
|
+
if (!reader) return;
|
|
89
|
+
try {
|
|
90
|
+
while (!this.#closed) {
|
|
91
|
+
const {done, value} = await reader.read();
|
|
92
|
+
if (done) break;
|
|
93
|
+
/* istanbul ignore else — InMemorySerialTransport never emits empty chunks */
|
|
94
|
+
if (value && value.length > 0) {
|
|
95
|
+
for (let i = 0; i < value.length; i++) this.#pending.push(value[i]);
|
|
96
|
+
this.#wake();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// read errored (e.g. device lost) — surface as end-of-stream to waiters
|
|
101
|
+
} finally {
|
|
102
|
+
this.#done = true;
|
|
103
|
+
this.#wake();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#wake(): void {
|
|
108
|
+
const waiters = this.#waiters;
|
|
109
|
+
this.#waiters = [];
|
|
110
|
+
for (const w of waiters) w();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Resolve on the next #pending change / stream end, or reject after `ms`. */
|
|
114
|
+
#waitForChange(ms: number): Promise<void> {
|
|
115
|
+
return new Promise<void>((resolve, reject) => {
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
this.#waiters = this.#waiters.filter(w => w !== waiter);
|
|
118
|
+
reject(new Error('read timeout'));
|
|
119
|
+
}, ms);
|
|
120
|
+
const waiter = () => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
resolve();
|
|
123
|
+
};
|
|
124
|
+
this.#waiters.push(waiter);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Block until `ready(buffer)` returns a non-negative count of bytes to
|
|
130
|
+
* consume, then splice and return them. Rejects with `label` on timeout.
|
|
131
|
+
*/
|
|
132
|
+
async #consume(
|
|
133
|
+
ready: (buffer: number[]) => number | false,
|
|
134
|
+
timeoutMs: number,
|
|
135
|
+
label: () => string,
|
|
136
|
+
): Promise<Uint8Array> {
|
|
137
|
+
const deadline = Date.now() + timeoutMs;
|
|
138
|
+
while (true) {
|
|
139
|
+
const take = ready(this.#pending);
|
|
140
|
+
if (take !== false) {
|
|
141
|
+
return Uint8Array.from(this.#pending.splice(0, take));
|
|
142
|
+
}
|
|
143
|
+
if (this.#done) {
|
|
144
|
+
if (this.#pending.length > 0) {
|
|
145
|
+
// Return whatever is buffered rather than hang on a closed stream.
|
|
146
|
+
return Uint8Array.from(this.#pending.splice(0, this.#pending.length));
|
|
147
|
+
}
|
|
148
|
+
// The transport can surface end-of-stream before the final queued data
|
|
149
|
+
// event has been pumped into #pending. Give that last turn a chance to
|
|
150
|
+
// land before we conclude there is nothing buffered.
|
|
151
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
152
|
+
/* istanbul ignore next -- race-guard checks again after yielding */
|
|
153
|
+
if (this.#pending.length === 0) {
|
|
154
|
+
return Uint8Array.from([]);
|
|
155
|
+
}
|
|
156
|
+
/* istanbul ignore next -- race-guard re-enters the loop after yielding */
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const remaining = deadline - Date.now();
|
|
160
|
+
if (remaining <= 0) throw new Error(label());
|
|
161
|
+
try {
|
|
162
|
+
await this.#waitForChange(remaining);
|
|
163
|
+
} catch {
|
|
164
|
+
throw new Error(label());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Write bytes (string → char codes & 0xff). */
|
|
170
|
+
async write(data: number[] | Uint8Array | string): Promise<void> {
|
|
171
|
+
if (!this.#writer) throw new Error('SerialClient is not open.');
|
|
172
|
+
await this.#writer.write(Uint8Array.from(toBytes(data)));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Read exactly `n` bytes (fewer only if the stream ends first). */
|
|
176
|
+
readBytes(n: number, options: ReadOptions = {}): Promise<Uint8Array> {
|
|
177
|
+
const timeout = options.timeout ?? this.#defaultTimeout;
|
|
178
|
+
return this.#consume(
|
|
179
|
+
buf => (buf.length >= n ? n : false),
|
|
180
|
+
timeout,
|
|
181
|
+
() => `timed out reading ${n} bytes (got ${this.#pending.length})`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Read up to and including `delimiter`; returns the slice (incl. delimiter). */
|
|
186
|
+
readUntil(
|
|
187
|
+
delimiter: number[] | Uint8Array | string,
|
|
188
|
+
options: ReadOptions = {},
|
|
189
|
+
): Promise<Uint8Array> {
|
|
190
|
+
const needle = toBytes(delimiter);
|
|
191
|
+
const timeout = options.timeout ?? this.#defaultTimeout;
|
|
192
|
+
return this.#consume(
|
|
193
|
+
buf => {
|
|
194
|
+
const idx = indexOfSubsequence(buf, needle);
|
|
195
|
+
return idx < 0 ? false : idx + needle.length;
|
|
196
|
+
},
|
|
197
|
+
timeout,
|
|
198
|
+
() => `timed out reading until delimiter`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Read one `\n`-terminated line as text (trailing CR/LF stripped). */
|
|
203
|
+
async readLine(options: ReadOptions = {}): Promise<string> {
|
|
204
|
+
const bytes = await this.readUntil('\n', options);
|
|
205
|
+
let end = bytes.length;
|
|
206
|
+
if (end > 0 && bytes[end - 1] === 0x0a) end--;
|
|
207
|
+
if (end > 0 && bytes[end - 1] === 0x0d) end--;
|
|
208
|
+
return String.fromCharCode(...bytes.subarray(0, end));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Frame a message: `predicate(buffer)` returns the number of leading bytes the
|
|
213
|
+
* next frame occupies (consumed and returned), or `false` to wait for more.
|
|
214
|
+
*/
|
|
215
|
+
readMatching(
|
|
216
|
+
predicate: (buffer: Uint8Array) => number | false,
|
|
217
|
+
options: ReadOptions = {},
|
|
218
|
+
): Promise<Uint8Array> {
|
|
219
|
+
const timeout = options.timeout ?? this.#defaultTimeout;
|
|
220
|
+
return this.#consume(
|
|
221
|
+
buf => predicate(Uint8Array.from(buf)),
|
|
222
|
+
timeout,
|
|
223
|
+
() => `timed out waiting for a frame`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolve with the next chunk of buffered inbound bytes (one or more), or an
|
|
229
|
+
* empty array once the stream has ended. Rejects on timeout. Use this to layer
|
|
230
|
+
* your own framing/decoder (SLIP, COBS, length-prefix, …) on the byte stream;
|
|
231
|
+
* pair it with {@link ended} to stop when the device goes away.
|
|
232
|
+
*/
|
|
233
|
+
readAvailable(options: ReadOptions = {}): Promise<Uint8Array> {
|
|
234
|
+
const timeout = options.timeout ?? this.#defaultTimeout;
|
|
235
|
+
return this.#consume(
|
|
236
|
+
buf => (buf.length > 0 ? buf.length : false),
|
|
237
|
+
timeout,
|
|
238
|
+
() => `timed out waiting for data`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Assert no inbound bytes arrive for `ms`. Rejects if any do. */
|
|
243
|
+
async expectIdle(ms: number): Promise<void> {
|
|
244
|
+
if (this.#pending.length > 0) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`expected no inbound data but ${this.#pending.length} byte(s) were already buffered`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
await this.#waitForChange(ms);
|
|
251
|
+
} catch {
|
|
252
|
+
return; // no change within the window — idle, as expected
|
|
253
|
+
}
|
|
254
|
+
if (this.#pending.length > 0) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`expected no inbound data for ${ms}ms but received ${this.#pending.length} byte(s)`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
// stream ended with no data — also idle
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Drop any buffered-but-unread inbound bytes. */
|
|
263
|
+
drain(): void {
|
|
264
|
+
this.#pending.length = 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Release reader + writer locks and close the port. Safe to call twice. */
|
|
268
|
+
async close(): Promise<void> {
|
|
269
|
+
if (this.#closed) return;
|
|
270
|
+
this.#closed = true;
|
|
271
|
+
this.#wake();
|
|
272
|
+
if (this.#reader) {
|
|
273
|
+
try {
|
|
274
|
+
await this.#reader.cancel();
|
|
275
|
+
} catch {
|
|
276
|
+
// ignore
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
this.#reader.releaseLock();
|
|
280
|
+
} catch {
|
|
281
|
+
// already released
|
|
282
|
+
}
|
|
283
|
+
this.#reader = null;
|
|
284
|
+
}
|
|
285
|
+
if (this.#writer) {
|
|
286
|
+
try {
|
|
287
|
+
await this.#writer.close();
|
|
288
|
+
} catch {
|
|
289
|
+
// ignore
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
this.#writer.releaseLock();
|
|
293
|
+
} catch {
|
|
294
|
+
// already released
|
|
295
|
+
}
|
|
296
|
+
this.#writer = null;
|
|
297
|
+
}
|
|
298
|
+
this.#open = false;
|
|
299
|
+
try {
|
|
300
|
+
await this.#port.close();
|
|
301
|
+
} catch {
|
|
302
|
+
// already closed
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Create a {@link SerialClient} for any SerialPort (virtual, real, or WS). */
|
|
308
|
+
export function createSerialClient(
|
|
309
|
+
port: SerialPort,
|
|
310
|
+
options?: SerialClientOptions,
|
|
311
|
+
): SerialClient {
|
|
312
|
+
return new SerialClient(port, options);
|
|
313
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SimulatedDevice — author a complete simulated serial peripheral.
|
|
3
|
+
*
|
|
4
|
+
* Extend the class and override the lifecycle hooks to model a real device's
|
|
5
|
+
* whole behaviour (firmware/protocol): react to `open()`, to bytes the host
|
|
6
|
+
* writes, to control-signal changes, and stream data back over time. It is
|
|
7
|
+
* hosted by an {@link InMemorySerialTransport} ({@link ./virtual-serial}) and is
|
|
8
|
+
* free of any `react-native` dependency, so the same device runs under Jest,
|
|
9
|
+
* in a browser, and inside a React Native app on a device/emulator (for E2E).
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* class Thermometer extends SimulatedDevice {
|
|
13
|
+
* usbVendorId = 0x0403;
|
|
14
|
+
* usbProductId = 0x6001;
|
|
15
|
+
* #timer?: ReturnType<typeof setInterval>;
|
|
16
|
+
* onOpen() {
|
|
17
|
+
* this.#timer = setInterval(() => this.send(`${20 + Math.random()}C\r\n`), 1000);
|
|
18
|
+
* }
|
|
19
|
+
* onData(bytes) { // host wrote a command
|
|
20
|
+
* if (String.fromCharCode(...bytes).trim() === 'ID?') this.send('ACME-TEMP\r\n');
|
|
21
|
+
* }
|
|
22
|
+
* onClose() { clearInterval(this.#timer); }
|
|
23
|
+
* }
|
|
24
|
+
* transport.addDevice(new Thermometer(), {hasPermission: true});
|
|
25
|
+
*/
|
|
26
|
+
import type {OpenOptions} from '../transport';
|
|
27
|
+
|
|
28
|
+
/** The negotiated connection parameters (native parity code: 0 none/1 odd/2 even). */
|
|
29
|
+
export type SimulatedDeviceOpenOptions = Required<OpenOptions>;
|
|
30
|
+
|
|
31
|
+
/** Device-asserted input signals — what the host reads via getSignals(). */
|
|
32
|
+
export type SerialInputSignals = {
|
|
33
|
+
dataCarrierDetect?: boolean;
|
|
34
|
+
clearToSend?: boolean;
|
|
35
|
+
ringIndicator?: boolean;
|
|
36
|
+
dataSetReady?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Host-asserted output signals (DTR/RTS/break) the device observes. */
|
|
40
|
+
export type HostSignals = {
|
|
41
|
+
dataTerminalReady: boolean;
|
|
42
|
+
requestToSend: boolean;
|
|
43
|
+
break: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The handle a {@link SimulatedDevice} uses to talk back to the host. Provided by
|
|
48
|
+
* the transport; you normally use the `protected` helpers on `SimulatedDevice`
|
|
49
|
+
* rather than this directly.
|
|
50
|
+
*/
|
|
51
|
+
export interface SimulatedDeviceHost {
|
|
52
|
+
readonly deviceId: number;
|
|
53
|
+
readonly portNumber: number;
|
|
54
|
+
readonly isOpen: boolean;
|
|
55
|
+
readonly openOptions: SimulatedDeviceOpenOptions | null;
|
|
56
|
+
send(bytes: number[]): void;
|
|
57
|
+
raiseError(message: string, name?: string): void;
|
|
58
|
+
setSignals(signals: SerialInputSignals): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Coerce a payload into a byte array (string → char codes, & 0xff). */
|
|
62
|
+
export function toBytes(data: number[] | Uint8Array | string): number[] {
|
|
63
|
+
if (typeof data === 'string') {
|
|
64
|
+
return Array.from(data, c => c.charCodeAt(0) & 0xff);
|
|
65
|
+
}
|
|
66
|
+
if (data instanceof Uint8Array) return Array.from(data);
|
|
67
|
+
return data.map(n => n & 0xff);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Base class for a simulated serial peripheral. Override the `on*` hooks you
|
|
72
|
+
* care about (all default to no-ops and may be async) and use the `protected`
|
|
73
|
+
* helpers to drive the host.
|
|
74
|
+
*/
|
|
75
|
+
export abstract class SimulatedDevice {
|
|
76
|
+
/** USB Vendor ID this device reports for enumeration. */
|
|
77
|
+
abstract readonly usbVendorId: number;
|
|
78
|
+
/** USB Product ID this device reports for enumeration. */
|
|
79
|
+
abstract readonly usbProductId: number;
|
|
80
|
+
/** Optional USB serial number. */
|
|
81
|
+
readonly serialNumber?: string;
|
|
82
|
+
|
|
83
|
+
#host: SimulatedDeviceHost | null = null;
|
|
84
|
+
|
|
85
|
+
/** @internal Bind the transport host (called by InMemorySerialTransport). */
|
|
86
|
+
_bind(host: SimulatedDeviceHost): void {
|
|
87
|
+
this.#host = host;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Send bytes to the host (they appear on `port.readable`). */
|
|
91
|
+
protected send(data: number[] | Uint8Array | string): void {
|
|
92
|
+
this.#host?.send(toBytes(data));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Raise a typed read error on the host's readable stream. `name` is the W3C
|
|
97
|
+
* error type, e.g. "BreakError", "BufferOverrunError", "FramingError",
|
|
98
|
+
* "ParityError" (defaults to "NetworkError").
|
|
99
|
+
*/
|
|
100
|
+
protected raiseError(message: string, name?: string): void {
|
|
101
|
+
this.#host?.raiseError(message, name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Set device-asserted input signals (DCD/CTS/RI/DSR) the host can read. */
|
|
105
|
+
protected setSignals(signals: SerialInputSignals): void {
|
|
106
|
+
this.#host?.setSignals(signals);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The parameters the host opened the port with, or null when closed. */
|
|
110
|
+
protected get openOptions(): SimulatedDeviceOpenOptions | null {
|
|
111
|
+
return this.#host?.openOptions ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
protected get deviceId(): number {
|
|
115
|
+
return this.#host?.deviceId ?? -1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
protected get portNumber(): number {
|
|
119
|
+
return this.#host?.portNumber ?? 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The host opened the port. */
|
|
123
|
+
onOpen(_options: SimulatedDeviceOpenOptions): void | Promise<void> {}
|
|
124
|
+
/** The host wrote bytes to the device. */
|
|
125
|
+
onData(_data: Uint8Array): void | Promise<void> {}
|
|
126
|
+
/** The host changed DTR/RTS/break. */
|
|
127
|
+
onHostSignals(_signals: HostSignals): void | Promise<void> {}
|
|
128
|
+
/** The host closed the port. */
|
|
129
|
+
onClose(): void | Promise<void> {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Optional USB identity for built-in devices. */
|
|
133
|
+
export type SimulatedDeviceIdentity = {
|
|
134
|
+
usbVendorId?: number;
|
|
135
|
+
usbProductId?: number;
|
|
136
|
+
serialNumber?: string;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** A loopback device: every byte written is echoed straight back. */
|
|
140
|
+
export class LoopbackDevice extends SimulatedDevice {
|
|
141
|
+
readonly usbVendorId: number;
|
|
142
|
+
readonly usbProductId: number;
|
|
143
|
+
readonly serialNumber?: string;
|
|
144
|
+
|
|
145
|
+
constructor(identity: SimulatedDeviceIdentity = {}) {
|
|
146
|
+
super();
|
|
147
|
+
this.usbVendorId = identity.usbVendorId ?? 0x0403;
|
|
148
|
+
this.usbProductId = identity.usbProductId ?? 0x6001;
|
|
149
|
+
this.serialNumber = identity.serialNumber;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onData(data: Uint8Array): void {
|
|
153
|
+
this.send(data);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Base for a line-oriented command/response device: buffers incoming bytes and
|
|
159
|
+
* calls {@link onLine} for each `\n`-terminated line (trailing CR/LF stripped).
|
|
160
|
+
*/
|
|
161
|
+
export abstract class LineBufferedDevice extends SimulatedDevice {
|
|
162
|
+
#buffer = '';
|
|
163
|
+
|
|
164
|
+
/** Handle one line received from the host. */
|
|
165
|
+
abstract onLine(line: string): void | Promise<void>;
|
|
166
|
+
|
|
167
|
+
onData(data: Uint8Array): void {
|
|
168
|
+
for (let i = 0; i < data.length; i++) {
|
|
169
|
+
this.#buffer += String.fromCharCode(data[i]);
|
|
170
|
+
}
|
|
171
|
+
let nl = this.#buffer.indexOf('\n');
|
|
172
|
+
while (nl >= 0) {
|
|
173
|
+
const line = this.#buffer.slice(0, nl).replace(/\r$/, '');
|
|
174
|
+
this.#buffer = this.#buffer.slice(nl + 1);
|
|
175
|
+
void this.onLine(line);
|
|
176
|
+
nl = this.#buffer.indexOf('\n');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A device that accepts writes but never sends anything back. */
|
|
182
|
+
export class SinkDevice extends SimulatedDevice {
|
|
183
|
+
readonly usbVendorId: number;
|
|
184
|
+
readonly usbProductId: number;
|
|
185
|
+
readonly serialNumber?: string;
|
|
186
|
+
|
|
187
|
+
constructor(identity: SimulatedDeviceIdentity = {}) {
|
|
188
|
+
super();
|
|
189
|
+
this.usbVendorId = identity.usbVendorId ?? 0x0403;
|
|
190
|
+
this.usbProductId = identity.usbProductId ?? 0x6001;
|
|
191
|
+
this.serialNumber = identity.serialNumber;
|
|
192
|
+
}
|
|
193
|
+
}
|