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,659 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.WebSocketSerialTransport = void 0;
|
|
7
|
+
var _protocol = require("./protocol");
|
|
8
|
+
/**
|
|
9
|
+
* A {@link SerialTransport} that talks to a remote serial port over a WebSocket
|
|
10
|
+
* bridge (run `expose-serial-websocket` on the host — see `bin/expose-serial.js`).
|
|
11
|
+
*
|
|
12
|
+
* Because it implements the same transport contract as the native `UsbSerialModule` and the
|
|
13
|
+
* in-memory `InMemorySerialTransport`, it's a drop-in: the whole `Serial` /
|
|
14
|
+
* `SerialPort` polyfill (streams, signals, reconnect, the conformance suite)
|
|
15
|
+
* works on top of it unchanged.
|
|
16
|
+
*
|
|
17
|
+
* **Resilience.** The socket is supervised: an unexpected drop (server restart,
|
|
18
|
+
* network blip) is handled transparently — the transport reconnects with
|
|
19
|
+
* exponential backoff and *restores the session* (line coding, control signals,
|
|
20
|
+
* and read state) so the app's open `SerialPort` keeps working. A transient
|
|
21
|
+
* drop is therefore invisible to the polyfill (reads pause and resume; writes
|
|
22
|
+
* issued during the gap wait for the reconnection). Only a real remote
|
|
23
|
+
* disconnect (the host's serial port going away) or giving up after
|
|
24
|
+
* `maxReconnectAttempts` surfaces as a serial disconnect.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* import {Serial} from 'react-native-web-serial-api';
|
|
28
|
+
* import {WebSocketSerialTransport} from 'react-native-web-serial-api/websocket';
|
|
29
|
+
*
|
|
30
|
+
* const serial = new Serial(new WebSocketSerialTransport('ws://localhost:8080'));
|
|
31
|
+
* const [port] = await serial.getPorts();
|
|
32
|
+
* await port.open({baudRate: 115200});
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Minimal structural WebSocket shape (browser & React Native both provide it). */
|
|
36
|
+
|
|
37
|
+
const PARITY = {
|
|
38
|
+
0: 'none',
|
|
39
|
+
1: 'odd',
|
|
40
|
+
2: 'even'
|
|
41
|
+
};
|
|
42
|
+
class WebSocketSerialTransport {
|
|
43
|
+
#deviceId = 1;
|
|
44
|
+
#portNumber = 0;
|
|
45
|
+
#usbVendorId;
|
|
46
|
+
#usbProductId;
|
|
47
|
+
#serialNumber;
|
|
48
|
+
#url;
|
|
49
|
+
#Ctor;
|
|
50
|
+
#reconnectEnabled;
|
|
51
|
+
#initialDelay;
|
|
52
|
+
#maxDelay;
|
|
53
|
+
#maxAttempts;
|
|
54
|
+
#connectTimeout;
|
|
55
|
+
#commandTimeout;
|
|
56
|
+
#options;
|
|
57
|
+
#ws = null;
|
|
58
|
+
#state = 'connecting';
|
|
59
|
+
#everConnected = false;
|
|
60
|
+
#reconnectAttempts = 0;
|
|
61
|
+
#reconnectTimer;
|
|
62
|
+
#metadataLoaded = false;
|
|
63
|
+
|
|
64
|
+
// Session state to restore on reconnect.
|
|
65
|
+
#portOpen = false;
|
|
66
|
+
#reading = false;
|
|
67
|
+
#lastLineCoding = null;
|
|
68
|
+
#nextId = 1;
|
|
69
|
+
#dtr = false;
|
|
70
|
+
#rts = false;
|
|
71
|
+
#pending = new Map();
|
|
72
|
+
#connectWaiters = new Set();
|
|
73
|
+
#dataListeners = new Set();
|
|
74
|
+
#errorListeners = new Set();
|
|
75
|
+
#connectListeners = new Set();
|
|
76
|
+
#disconnectListeners = new Set();
|
|
77
|
+
constructor(url, options = {}) {
|
|
78
|
+
this.#options = options;
|
|
79
|
+
this.#usbVendorId = options.usbVendorId ?? 0;
|
|
80
|
+
this.#usbProductId = options.usbProductId ?? 0;
|
|
81
|
+
this.#serialNumber = options.serialNumber ?? '';
|
|
82
|
+
const Ctor = options.WebSocket ?? globalThis.WebSocket;
|
|
83
|
+
if (!Ctor) {
|
|
84
|
+
throw new Error('No WebSocket implementation is available.');
|
|
85
|
+
}
|
|
86
|
+
this.#url = url;
|
|
87
|
+
this.#Ctor = Ctor;
|
|
88
|
+
this.#reconnectEnabled = options.reconnect ?? true;
|
|
89
|
+
this.#initialDelay = options.reconnectInitialDelayMs ?? 250;
|
|
90
|
+
this.#maxDelay = options.reconnectMaxDelayMs ?? 10000;
|
|
91
|
+
this.#maxAttempts = options.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
|
|
92
|
+
this.#connectTimeout = options.connectTimeoutMs ?? 10000;
|
|
93
|
+
this.#commandTimeout = options.commandTimeoutMs ?? 10000;
|
|
94
|
+
this.#connect();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Current connection state (advisory; the polyfill keeps working across reconnects). */
|
|
98
|
+
get connectionState() {
|
|
99
|
+
return this.#state;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── connection lifecycle ────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
#connect() {
|
|
105
|
+
/* istanbul ignore next — reconnect timers are cleared on explicit disconnect() */
|
|
106
|
+
if (this.#state === 'closed') {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.#state = this.#everConnected ? 'reconnecting' : 'connecting';
|
|
110
|
+
let ws;
|
|
111
|
+
try {
|
|
112
|
+
ws = new this.#Ctor(this.#url);
|
|
113
|
+
} catch {
|
|
114
|
+
// Construction itself failed (e.g. bad URL on some impls) — treat as a drop.
|
|
115
|
+
this.#handleSocketDown();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
ws.binaryType = 'arraybuffer';
|
|
119
|
+
this.#ws = ws;
|
|
120
|
+
let dead = false;
|
|
121
|
+
const onDown = () => {
|
|
122
|
+
// Fire once per socket, and ignore a stale socket we've already replaced.
|
|
123
|
+
if (dead || ws !== this.#ws) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
dead = true;
|
|
127
|
+
this.#handleSocketDown();
|
|
128
|
+
};
|
|
129
|
+
ws.addEventListener('open', () => {
|
|
130
|
+
if (ws === this.#ws) {
|
|
131
|
+
void this.#onOpen();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
ws.addEventListener('message', event => {
|
|
135
|
+
if (ws === this.#ws) {
|
|
136
|
+
this.#onMessage(event.data);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
ws.addEventListener('error', onDown);
|
|
140
|
+
ws.addEventListener('close', onDown);
|
|
141
|
+
}
|
|
142
|
+
async #onOpen() {
|
|
143
|
+
const reconnected = this.#everConnected;
|
|
144
|
+
this.#everConnected = true;
|
|
145
|
+
this.#state = 'open';
|
|
146
|
+
this.#reconnectAttempts = 0;
|
|
147
|
+
try {
|
|
148
|
+
// Re-establish the remote session before letting queued I/O resume, so
|
|
149
|
+
// writes never go out at the wrong baud rate / signal state.
|
|
150
|
+
await this.#restoreSession();
|
|
151
|
+
await this.#loadPortInfo();
|
|
152
|
+
} catch {
|
|
153
|
+
// A drop happened mid-restore; the close handler schedules another retry.
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!reconnected) {
|
|
157
|
+
this.#emit(this.#connectListeners, this.#connectEvent());
|
|
158
|
+
}
|
|
159
|
+
this.#options.onConnected?.({
|
|
160
|
+
reconnected
|
|
161
|
+
});
|
|
162
|
+
this.#releaseWaiters();
|
|
163
|
+
}
|
|
164
|
+
async #restoreSession() {
|
|
165
|
+
if (!this.#portOpen) {
|
|
166
|
+
return; // nothing to restore until the app has opened the port
|
|
167
|
+
}
|
|
168
|
+
/* istanbul ignore next */
|
|
169
|
+
if (this.#lastLineCoding) {
|
|
170
|
+
await this.#sendCommand('setLineCoding', {
|
|
171
|
+
...this.#lastLineCoding
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (this.#dtr || this.#rts) {
|
|
175
|
+
await this.#sendCommand('setSignals', {
|
|
176
|
+
dtr: this.#dtr,
|
|
177
|
+
rts: this.#rts
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// A fresh bridge connection forwards data by default; mirror the app's intent.
|
|
181
|
+
await this.#sendCommand(this.#reading ? 'startReading' : 'stopReading');
|
|
182
|
+
}
|
|
183
|
+
async #loadPortInfo() {
|
|
184
|
+
if (this.#metadataLoaded) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
let info = null;
|
|
188
|
+
try {
|
|
189
|
+
info = await this.#sendCommand('getPortInfo');
|
|
190
|
+
} catch {
|
|
191
|
+
// Older bridge versions won't implement getPortInfo; keep defaults.
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (info && typeof info === 'object') {
|
|
195
|
+
if (typeof info.usbVendorId === 'number') {
|
|
196
|
+
this.#usbVendorId = info.usbVendorId;
|
|
197
|
+
}
|
|
198
|
+
if (typeof info.usbProductId === 'number') {
|
|
199
|
+
this.#usbProductId = info.usbProductId;
|
|
200
|
+
}
|
|
201
|
+
if (typeof info.serialNumber === 'string') {
|
|
202
|
+
this.#serialNumber = info.serialNumber;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
this.#metadataLoaded = true;
|
|
206
|
+
}
|
|
207
|
+
async #probeAlive() {
|
|
208
|
+
try {
|
|
209
|
+
// `getSignals` is supported by all bridge versions and gives us a
|
|
210
|
+
// request/response roundtrip to detect half-open sockets.
|
|
211
|
+
await this.#sendCommand('getSignals');
|
|
212
|
+
} catch (error) {
|
|
213
|
+
this.#handleSocketDown();
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
#handleSocketDown() {
|
|
218
|
+
if (this.#state === 'closed') {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Commands tied to the dead socket can never be answered — reject them.
|
|
222
|
+
this.#failAll('WebSocket connection lost');
|
|
223
|
+
if (this.#reconnectEnabled && this.#reconnectAttempts < this.#maxAttempts) {
|
|
224
|
+
this.#scheduleReconnect();
|
|
225
|
+
} else {
|
|
226
|
+
this.#terminate('WebSocket connection lost and will not be retried.');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
#scheduleReconnect() {
|
|
230
|
+
this.#state = 'reconnecting';
|
|
231
|
+
const attempt = this.#reconnectAttempts++; // 0-based for the backoff curve
|
|
232
|
+
const delay = Math.min(this.#initialDelay * 2 ** attempt, this.#maxDelay);
|
|
233
|
+
// Full jitter in the upper half keeps a herd of clients from syncing up.
|
|
234
|
+
const jittered = Math.round(delay * (0.5 + Math.random() * 0.5));
|
|
235
|
+
this.#options.onReconnecting?.(attempt + 1, jittered);
|
|
236
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
237
|
+
this.#reconnectTimer = undefined;
|
|
238
|
+
this.#connect();
|
|
239
|
+
}, jittered);
|
|
240
|
+
}
|
|
241
|
+
#terminate(reason) {
|
|
242
|
+
/* istanbul ignore next — terminate() only runs once per transport lifecycle */
|
|
243
|
+
if (this.#state === 'closed') {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.#state = 'closed';
|
|
247
|
+
this.#portOpen = false;
|
|
248
|
+
this.#reading = false;
|
|
249
|
+
/* istanbul ignore next — reconnect timers are either fired or cleared earlier */
|
|
250
|
+
if (this.#reconnectTimer !== undefined) {
|
|
251
|
+
clearTimeout(this.#reconnectTimer);
|
|
252
|
+
this.#reconnectTimer = undefined;
|
|
253
|
+
}
|
|
254
|
+
this.#failAll(reason);
|
|
255
|
+
this.#rejectWaiters(new Error(reason));
|
|
256
|
+
// Surface to the polyfill so any open stream fails with a NetworkError.
|
|
257
|
+
this.#emit(this.#errorListeners, {
|
|
258
|
+
deviceId: this.#deviceId,
|
|
259
|
+
portNumber: this.#portNumber,
|
|
260
|
+
error: reason
|
|
261
|
+
});
|
|
262
|
+
this.#emit(this.#disconnectListeners, this.#connectEvent());
|
|
263
|
+
this.#options.onClosed?.(reason);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Release the socket because the consumer closed the port. Unlike a drop, this
|
|
268
|
+
* does not auto-reconnect (no lingering bridge session) — the next `open()` or
|
|
269
|
+
* discovery reconnects on demand. Distinct from the terminal `disconnect()`.
|
|
270
|
+
*/
|
|
271
|
+
#suspend() {
|
|
272
|
+
if (this.#reconnectTimer !== undefined) {
|
|
273
|
+
clearTimeout(this.#reconnectTimer);
|
|
274
|
+
this.#reconnectTimer = undefined;
|
|
275
|
+
}
|
|
276
|
+
this.#reconnectAttempts = 0;
|
|
277
|
+
this.#failAll('serial port closed');
|
|
278
|
+
this.#state = 'suspended';
|
|
279
|
+
const ws = this.#ws;
|
|
280
|
+
this.#ws = null; // detach first so the socket's close handler is a no-op
|
|
281
|
+
try {
|
|
282
|
+
ws?.close();
|
|
283
|
+
} catch {
|
|
284
|
+
// already closing
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Resolve when the socket is connected; reject on timeout or terminal close.
|
|
290
|
+
* Wakes a `suspended` transport (after `close()`) by reconnecting on demand.
|
|
291
|
+
*/
|
|
292
|
+
#whenConnected() {
|
|
293
|
+
if (this.#state === 'open') {
|
|
294
|
+
return Promise.resolve();
|
|
295
|
+
}
|
|
296
|
+
if (this.#state === 'closed') {
|
|
297
|
+
return Promise.reject(new Error('WebSocket transport is closed.'));
|
|
298
|
+
}
|
|
299
|
+
if (this.#state === 'suspended') {
|
|
300
|
+
this.#connect(); // reconnect on demand
|
|
301
|
+
}
|
|
302
|
+
return new Promise((resolve, reject) => {
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
this.#connectWaiters.delete(waiter);
|
|
305
|
+
reject(new Error('Timed out waiting for the WebSocket connection.'));
|
|
306
|
+
}, this.#connectTimeout);
|
|
307
|
+
const waiter = {
|
|
308
|
+
resolve: () => {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
resolve();
|
|
311
|
+
},
|
|
312
|
+
reject: e => {
|
|
313
|
+
clearTimeout(timer);
|
|
314
|
+
reject(e);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
this.#connectWaiters.add(waiter);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
#releaseWaiters() {
|
|
321
|
+
for (const waiter of [...this.#connectWaiters]) {
|
|
322
|
+
waiter.resolve();
|
|
323
|
+
}
|
|
324
|
+
this.#connectWaiters.clear();
|
|
325
|
+
}
|
|
326
|
+
#rejectWaiters(reason) {
|
|
327
|
+
for (const waiter of [...this.#connectWaiters]) {
|
|
328
|
+
waiter.reject(reason);
|
|
329
|
+
}
|
|
330
|
+
this.#connectWaiters.clear();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── message handling ───────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
#onMessage(data) {
|
|
336
|
+
if (data instanceof ArrayBuffer) {
|
|
337
|
+
this.#emit(this.#dataListeners, {
|
|
338
|
+
deviceId: this.#deviceId,
|
|
339
|
+
portNumber: this.#portNumber,
|
|
340
|
+
data: Array.from(new Uint8Array(data))
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (typeof data !== 'string') {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const msg = (0, _protocol.parseControlMessage)(data);
|
|
348
|
+
if (!msg) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (msg.type === 'response') {
|
|
352
|
+
const pending = this.#pending.get(msg.id);
|
|
353
|
+
if (pending) {
|
|
354
|
+
this.#pending.delete(msg.id);
|
|
355
|
+
if (msg.error) {
|
|
356
|
+
pending.reject(new Error(msg.error));
|
|
357
|
+
} else {
|
|
358
|
+
pending.resolve(msg.result);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} else if (msg.type === 'event') {
|
|
362
|
+
if (msg.event === 'error') {
|
|
363
|
+
this.#emit(this.#errorListeners, {
|
|
364
|
+
deviceId: this.#deviceId,
|
|
365
|
+
portNumber: this.#portNumber,
|
|
366
|
+
error: msg.error ?? 'serial error'
|
|
367
|
+
});
|
|
368
|
+
} else if (msg.event === 'close') {
|
|
369
|
+
// The host's serial port itself went away — a real disconnect (distinct
|
|
370
|
+
// from a transport drop, which we reconnect through silently).
|
|
371
|
+
this.#portOpen = false;
|
|
372
|
+
this.#emit(this.#disconnectListeners, this.#connectEvent());
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
#failAll(reason) {
|
|
377
|
+
for (const [, pending] of this.#pending) {
|
|
378
|
+
pending.reject(new Error(reason));
|
|
379
|
+
}
|
|
380
|
+
this.#pending.clear();
|
|
381
|
+
}
|
|
382
|
+
#connectEvent() {
|
|
383
|
+
return {
|
|
384
|
+
deviceId: this.#deviceId,
|
|
385
|
+
usbVendorId: this.#usbVendorId,
|
|
386
|
+
usbProductId: this.#usbProductId
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Register a pending command and send it on the (already-open) socket. */
|
|
391
|
+
#sendCommand(command, args) {
|
|
392
|
+
const ws = this.#ws;
|
|
393
|
+
if (!ws || this.#state === 'closed') {
|
|
394
|
+
return Promise.reject(new Error('WebSocket transport is closed.'));
|
|
395
|
+
}
|
|
396
|
+
const id = this.#nextId++;
|
|
397
|
+
return new Promise((resolve, reject) => {
|
|
398
|
+
const timer = setTimeout(() => {
|
|
399
|
+
if (this.#pending.delete(id)) {
|
|
400
|
+
reject(new Error(`Command "${command}" timed out.`));
|
|
401
|
+
}
|
|
402
|
+
}, this.#commandTimeout);
|
|
403
|
+
this.#pending.set(id, {
|
|
404
|
+
resolve: value => {
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
resolve(value);
|
|
407
|
+
},
|
|
408
|
+
reject: error => {
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
reject(error);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
try {
|
|
414
|
+
ws.send(JSON.stringify({
|
|
415
|
+
type: 'command',
|
|
416
|
+
id,
|
|
417
|
+
command,
|
|
418
|
+
args
|
|
419
|
+
}));
|
|
420
|
+
} catch (e) {
|
|
421
|
+
this.#pending.delete(id);
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Wait for a connection (reconnecting if necessary), then run a command. */
|
|
429
|
+
async #command(command, args) {
|
|
430
|
+
await this.#whenConnected();
|
|
431
|
+
return this.#sendCommand(command, args);
|
|
432
|
+
}
|
|
433
|
+
#subscribe(set, listener) {
|
|
434
|
+
set.add(listener);
|
|
435
|
+
return {
|
|
436
|
+
remove: () => set.delete(listener)
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
#emit(set, event) {
|
|
440
|
+
for (const listener of [...set]) {
|
|
441
|
+
listener(event);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── discovery & permission ─────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
async findAllDrivers() {
|
|
448
|
+
try {
|
|
449
|
+
await this.#whenConnected();
|
|
450
|
+
await this.#probeAlive();
|
|
451
|
+
await this.#loadPortInfo();
|
|
452
|
+
} catch {
|
|
453
|
+
// Discovery should be resilient: treat transport down as "no devices".
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
return [{
|
|
457
|
+
deviceId: this.#deviceId,
|
|
458
|
+
portNumber: this.#portNumber,
|
|
459
|
+
usbVendorId: this.#usbVendorId,
|
|
460
|
+
usbProductId: this.#usbProductId,
|
|
461
|
+
hasPermission: true
|
|
462
|
+
}];
|
|
463
|
+
}
|
|
464
|
+
async showPortPicker(_filter, _labels) {
|
|
465
|
+
const [port] = await this.findAllDrivers();
|
|
466
|
+
return port;
|
|
467
|
+
}
|
|
468
|
+
requestPermission(_deviceId) {
|
|
469
|
+
return Promise.resolve(true);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── lifecycle ──────────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
#lineCoding(options) {
|
|
475
|
+
return {
|
|
476
|
+
baudRate: options.baudRate,
|
|
477
|
+
dataBits: options.dataBits,
|
|
478
|
+
stopBits: options.stopBits,
|
|
479
|
+
parity: PARITY[options.parity ?? 0] ?? 'none'
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
async open(_deviceId, _portNumber, options) {
|
|
483
|
+
this.#lastLineCoding = this.#lineCoding(options);
|
|
484
|
+
await this.#command('setLineCoding', {
|
|
485
|
+
...this.#lastLineCoding
|
|
486
|
+
});
|
|
487
|
+
this.#portOpen = true;
|
|
488
|
+
}
|
|
489
|
+
async close(_deviceId, _portNumber) {
|
|
490
|
+
this.#portOpen = false;
|
|
491
|
+
this.#reading = false;
|
|
492
|
+
if (this.#state === 'closed' || this.#state === 'suspended') {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// Tell the bridge to stop forwarding, then drop the socket so the remote
|
|
496
|
+
// serial session is released and the device isn't left in a stale state.
|
|
497
|
+
// A later open() reconnects on demand (see #whenConnected → #suspend).
|
|
498
|
+
if (this.#state === 'open') {
|
|
499
|
+
await this.#sendCommand('stopReading').catch(() => undefined);
|
|
500
|
+
}
|
|
501
|
+
this.#suspend();
|
|
502
|
+
}
|
|
503
|
+
isOpen(_deviceId, _portNumber) {
|
|
504
|
+
return this.#portOpen;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── I/O ────────────────────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
async write(_deviceId, _portNumber, data, _timeout) {
|
|
510
|
+
await this.#whenConnected();
|
|
511
|
+
const ws = this.#ws;
|
|
512
|
+
/* istanbul ignore next — #whenConnected() guarantees an attached socket */
|
|
513
|
+
if (!ws) {
|
|
514
|
+
throw new Error('WebSocket transport is closed.');
|
|
515
|
+
}
|
|
516
|
+
ws.send(Uint8Array.from(data));
|
|
517
|
+
}
|
|
518
|
+
async startReading(_deviceId, _portNumber) {
|
|
519
|
+
this.#reading = true;
|
|
520
|
+
await this.#command('startReading');
|
|
521
|
+
}
|
|
522
|
+
async stopReading(_deviceId, _portNumber) {
|
|
523
|
+
this.#reading = false;
|
|
524
|
+
await this.#command('stopReading');
|
|
525
|
+
}
|
|
526
|
+
async setParameters(_deviceId, _portNumber, options) {
|
|
527
|
+
this.#lastLineCoding = this.#lineCoding(options);
|
|
528
|
+
await this.#command('setLineCoding', {
|
|
529
|
+
...this.#lastLineCoding
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── control signals ────────────────────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
async setDTR(_d, _p, value) {
|
|
536
|
+
this.#dtr = value;
|
|
537
|
+
await this.#command('setSignals', {
|
|
538
|
+
dtr: value
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
async setRTS(_d, _p, value) {
|
|
542
|
+
this.#rts = value;
|
|
543
|
+
await this.#command('setSignals', {
|
|
544
|
+
rts: value
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
getDTR(_d, _p) {
|
|
548
|
+
return Promise.resolve(this.#dtr);
|
|
549
|
+
}
|
|
550
|
+
getRTS(_d, _p) {
|
|
551
|
+
return Promise.resolve(this.#rts);
|
|
552
|
+
}
|
|
553
|
+
async #signals() {
|
|
554
|
+
const s = await this.#command('getSignals');
|
|
555
|
+
return {
|
|
556
|
+
cts: !!s?.cts,
|
|
557
|
+
dsr: !!s?.dsr,
|
|
558
|
+
dcd: !!s?.dcd,
|
|
559
|
+
ri: !!s?.ri
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
async getCD(_d, _p) {
|
|
563
|
+
return (await this.#signals()).dcd;
|
|
564
|
+
}
|
|
565
|
+
async getCTS(_d, _p) {
|
|
566
|
+
return (await this.#signals()).cts;
|
|
567
|
+
}
|
|
568
|
+
async getDSR(_d, _p) {
|
|
569
|
+
return (await this.#signals()).dsr;
|
|
570
|
+
}
|
|
571
|
+
async getRI(_d, _p) {
|
|
572
|
+
return (await this.#signals()).ri;
|
|
573
|
+
}
|
|
574
|
+
async getControlLines(_d, _p) {
|
|
575
|
+
const s = await this.#signals();
|
|
576
|
+
const lines = [];
|
|
577
|
+
if (this.#rts) lines.push('RTS');
|
|
578
|
+
if (this.#dtr) lines.push('DTR');
|
|
579
|
+
if (s.cts) lines.push('CTS');
|
|
580
|
+
if (s.dsr) lines.push('DSR');
|
|
581
|
+
if (s.dcd) lines.push('CD');
|
|
582
|
+
if (s.ri) lines.push('RI');
|
|
583
|
+
return lines;
|
|
584
|
+
}
|
|
585
|
+
getSupportedControlLines(_d, _p) {
|
|
586
|
+
return Promise.resolve(['RTS', 'CTS', 'DTR', 'DSR', 'CD', 'RI']);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── flow control ───────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
setFlowControl(_d, _p, _flowControl) {
|
|
592
|
+
// Flow control is fixed at the rate the bridge was started with; accept the
|
|
593
|
+
// call so open({flowControl}) doesn't fail, but it isn't changed live.
|
|
594
|
+
return Promise.resolve();
|
|
595
|
+
}
|
|
596
|
+
getFlowControl(_d, _p) {
|
|
597
|
+
return Promise.resolve('NONE');
|
|
598
|
+
}
|
|
599
|
+
getSupportedFlowControl(_d, _p) {
|
|
600
|
+
return Promise.resolve(['NONE', 'RTS_CTS']);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── misc ───────────────────────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
async setBreak(_d, _p, value) {
|
|
606
|
+
await this.#command('setSignals', {
|
|
607
|
+
brk: value
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
async purgeHwBuffers(_d, _p, _purgeWrite, _purgeRead) {
|
|
611
|
+
await this.#command('flush').catch(() => undefined);
|
|
612
|
+
}
|
|
613
|
+
getSerial(_d, _p) {
|
|
614
|
+
return Promise.resolve(this.#serialNumber);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ── subscriptions ──────────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
onData(listener) {
|
|
620
|
+
return this.#subscribe(this.#dataListeners, listener);
|
|
621
|
+
}
|
|
622
|
+
onError(listener) {
|
|
623
|
+
return this.#subscribe(this.#errorListeners, listener);
|
|
624
|
+
}
|
|
625
|
+
onConnect(listener) {
|
|
626
|
+
return this.#subscribe(this.#connectListeners, listener);
|
|
627
|
+
}
|
|
628
|
+
onDisconnect(listener) {
|
|
629
|
+
return this.#subscribe(this.#disconnectListeners, listener);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Permanently close the transport (and the remote port session). Cancels any
|
|
634
|
+
* pending reconnect and stops auto-reconnecting.
|
|
635
|
+
*/
|
|
636
|
+
disconnect() {
|
|
637
|
+
const alreadyClosed = this.#state === 'closed';
|
|
638
|
+
this.#state = 'closed'; // set first so the socket's close handler won't reconnect
|
|
639
|
+
if (this.#reconnectTimer !== undefined) {
|
|
640
|
+
clearTimeout(this.#reconnectTimer);
|
|
641
|
+
this.#reconnectTimer = undefined;
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
this.#ws?.close();
|
|
645
|
+
} catch {
|
|
646
|
+
// already closing
|
|
647
|
+
}
|
|
648
|
+
if (!alreadyClosed) {
|
|
649
|
+
this.#portOpen = false;
|
|
650
|
+
this.#reading = false;
|
|
651
|
+
this.#failAll('WebSocket transport closed by client');
|
|
652
|
+
this.#rejectWaiters(new Error('WebSocket transport is closed.'));
|
|
653
|
+
this.#emit(this.#disconnectListeners, this.#connectEvent());
|
|
654
|
+
this.#options.onClosed?.('closed by client');
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
exports.WebSocketSerialTransport = WebSocketSerialTransport;
|
|
659
|
+
//# sourceMappingURL=WebSocketSerialTransport.js.map
|