tasmota-webserial-esptool 7.3.4 → 9.0.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/css/dark.css +74 -0
- package/css/light.css +74 -0
- package/css/style.css +663 -35
- package/dist/esp_loader.d.ts +102 -14
- package/dist/esp_loader.js +1012 -188
- package/dist/index.d.ts +1 -0
- package/dist/index.js +31 -2
- package/dist/stubs/index.d.ts +1 -2
- package/dist/stubs/index.js +4 -0
- package/dist/web/index.js +1 -1
- package/js/modules/esptool.js +1 -1
- package/js/script.js +198 -85
- package/js/webusb-serial.js +1028 -0
- package/package.json +2 -2
- package/src/esp_loader.ts +1187 -220
- package/src/index.ts +38 -2
- package/src/stubs/index.ts +4 -1
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebUSBSerial - Web Serial API-like wrapper for WebUSB
|
|
3
|
+
* Provides a familiar interface for serial communication over USB on Android
|
|
4
|
+
*
|
|
5
|
+
* This enables ESP32Tool to work on Android devices where Web Serial API
|
|
6
|
+
* is not available but WebUSB is supported.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: For Android/Xiaomi compatibility, this class uses smaller transfer sizes
|
|
9
|
+
* to prevent SLIP synchronization errors. The maxTransferSize is set to 64 bytes
|
|
10
|
+
* (or endpoint packetSize if smaller) to ensure SLIP frames don't get split.
|
|
11
|
+
*/
|
|
12
|
+
class WebUSBSerial {
|
|
13
|
+
constructor(logger = null) {
|
|
14
|
+
this.device = null;
|
|
15
|
+
this.interfaceNumber = null;
|
|
16
|
+
this.endpointIn = null;
|
|
17
|
+
this.endpointOut = null;
|
|
18
|
+
this.controlInterface = null;
|
|
19
|
+
this.readableStream = null;
|
|
20
|
+
this.writableStream = null;
|
|
21
|
+
this._readLoopRunning = false;
|
|
22
|
+
this._usbDisconnectHandler = null;
|
|
23
|
+
this._eventListeners = {
|
|
24
|
+
'close': [],
|
|
25
|
+
'disconnect': []
|
|
26
|
+
};
|
|
27
|
+
// Transfer size optimized for WebUSB on Android/Xiaomi
|
|
28
|
+
// CRITICAL: blockSize = (maxTransferSize - 2) / 2
|
|
29
|
+
// Set to 64 bytes for maximum compatibility with all USB-Serial adapters
|
|
30
|
+
// With 64 bytes: blockSize = (64-2)/2 = 31 bytes per SLIP packet
|
|
31
|
+
this.maxTransferSize = 64;
|
|
32
|
+
|
|
33
|
+
// Flag to indicate this is WebUSB (used by esptool to adjust block sizes)
|
|
34
|
+
this.isWebUSB = true;
|
|
35
|
+
|
|
36
|
+
// Command queue for serializing control transfers (critical for CP2102)
|
|
37
|
+
this._commandQueue = Promise.resolve();
|
|
38
|
+
|
|
39
|
+
// Track current DTR/RTS state to preserve unspecified signals
|
|
40
|
+
this._currentDTR = false;
|
|
41
|
+
this._currentRTS = false;
|
|
42
|
+
|
|
43
|
+
// Logger function (defaults to console.log if not provided)
|
|
44
|
+
this._log = logger || ((...args) => console.log(...args));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Request USB device (mimics navigator.serial.requestPort())
|
|
49
|
+
* @param {function|object} logger - Logger function or object with log() method
|
|
50
|
+
* @param {boolean} forceNew - If true, forces selection of a new device (ignores already paired devices)
|
|
51
|
+
*/
|
|
52
|
+
static async requestPort(logger = null, forceNew = false) {
|
|
53
|
+
const filters = [
|
|
54
|
+
{ vendorId: 0x303A }, // Espressif
|
|
55
|
+
{ vendorId: 0x0403 }, // FTDI
|
|
56
|
+
{ vendorId: 0x1A86 }, // CH340
|
|
57
|
+
{ vendorId: 0x10C4 }, // CP210x
|
|
58
|
+
{ vendorId: 0x067B } // PL2303
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// Helper to call logger (supports both function and object with log() method)
|
|
62
|
+
const log = (msg) => {
|
|
63
|
+
if (!logger) return;
|
|
64
|
+
if (typeof logger === 'function') {
|
|
65
|
+
logger(msg);
|
|
66
|
+
} else if (typeof logger.log === 'function') {
|
|
67
|
+
logger.log(msg);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
let device;
|
|
72
|
+
|
|
73
|
+
// If forceNew is false, try to reuse a previously authorized device
|
|
74
|
+
if (!forceNew && navigator.usb && navigator.usb.getDevices) {
|
|
75
|
+
try {
|
|
76
|
+
const devices = await navigator.usb.getDevices();
|
|
77
|
+
// Find a device that matches our filters
|
|
78
|
+
device = devices.find(d =>
|
|
79
|
+
filters.some(f => f.vendorId === d.vendorId)
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (device) {
|
|
83
|
+
// Device already authorized, will reuse it
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Can't use this._log in static method, use console as fallback
|
|
87
|
+
console.warn('[WebUSB] Failed to get previously authorized devices:', err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// If no device found or forceNew is true, request a new device
|
|
92
|
+
if (!device) {
|
|
93
|
+
device = await navigator.usb.requestDevice({ filters });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const port = new WebUSBSerial(logger);
|
|
97
|
+
port.device = device;
|
|
98
|
+
return port;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Open the USB device (mimics port.open())
|
|
103
|
+
*/
|
|
104
|
+
async open(options = {}) {
|
|
105
|
+
if (!this.device) {
|
|
106
|
+
throw new Error('No device selected');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const baudRate = options.baudRate || 115200;
|
|
110
|
+
|
|
111
|
+
// If device is already opened, we need to close and reopen it
|
|
112
|
+
// This is critical for ESP32-S2 which changes interfaces when switching modes
|
|
113
|
+
if (this.device.opened) {
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Release all interfaces
|
|
117
|
+
if (this.interfaceNumber !== null) {
|
|
118
|
+
try { await this.device.releaseInterface(this.interfaceNumber); } catch (e) {}
|
|
119
|
+
}
|
|
120
|
+
if (this.controlInterface !== null && this.controlInterface !== this.interfaceNumber) {
|
|
121
|
+
try { await this.device.releaseInterface(this.controlInterface); } catch (e) {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Close the device
|
|
125
|
+
await this.device.close();
|
|
126
|
+
|
|
127
|
+
// Reset interface numbers so they get re-scanned
|
|
128
|
+
this.interfaceNumber = null;
|
|
129
|
+
this.controlInterface = null;
|
|
130
|
+
this.endpointIn = null;
|
|
131
|
+
this.endpointOut = null;
|
|
132
|
+
|
|
133
|
+
// Wait a bit for device to settle
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
this._log('[WebUSB] Error during close:', e.message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (this.device.opened) {
|
|
141
|
+
try { await this.device.close(); } catch (e) {
|
|
142
|
+
this._log('[WebUSB] Error closing device:', e.message);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
if (this.device.reset) {
|
|
148
|
+
await this.device.reset();
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
// this._log('[WebUSB] Device reset failed:', e.message);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const attemptOpenAndClaim = async () => {
|
|
155
|
+
await this.device.open();
|
|
156
|
+
try {
|
|
157
|
+
const currentCfg = this.device.configuration ? this.device.configuration.configurationValue : null;
|
|
158
|
+
if (!currentCfg || currentCfg !== 1) {
|
|
159
|
+
await this.device.selectConfiguration(1);
|
|
160
|
+
}
|
|
161
|
+
} catch (e) { }
|
|
162
|
+
|
|
163
|
+
const config = this.device.configuration;
|
|
164
|
+
|
|
165
|
+
// Try to claim CDC control interface first (helps on Android/CH34x)
|
|
166
|
+
const preControlIface = config.interfaces.find(i => i.alternates && i.alternates[0] && i.alternates[0].interfaceClass === 0x02);
|
|
167
|
+
if (preControlIface) {
|
|
168
|
+
try {
|
|
169
|
+
await this.device.claimInterface(preControlIface.interfaceNumber);
|
|
170
|
+
try { await this.device.selectAlternateInterface(preControlIface.interfaceNumber, 0); } catch (e) { }
|
|
171
|
+
this.controlInterface = preControlIface.interfaceNumber;
|
|
172
|
+
} catch (e) {
|
|
173
|
+
this._log(`[WebUSB] Could not pre-claim CDC control iface: ${e.message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Find bulk IN/OUT interface (prefer CDC data class)
|
|
178
|
+
const candidates = [];
|
|
179
|
+
for (const iface of config.interfaces) {
|
|
180
|
+
// Check all alternates, not just alternates[0]
|
|
181
|
+
for (let altIndex = 0; altIndex < iface.alternates.length; altIndex++) {
|
|
182
|
+
const alt = iface.alternates[altIndex];
|
|
183
|
+
let hasIn = false, hasOut = false;
|
|
184
|
+
for (const ep of alt.endpoints) {
|
|
185
|
+
if (ep.type === 'bulk' && ep.direction === 'in') hasIn = true;
|
|
186
|
+
if (ep.type === 'bulk' && ep.direction === 'out') hasOut = true;
|
|
187
|
+
}
|
|
188
|
+
if (hasIn && hasOut) {
|
|
189
|
+
let score = 2;
|
|
190
|
+
if (alt.interfaceClass === 0x0a) score = 0; // CDC data first
|
|
191
|
+
else if (alt.interfaceClass === 0xff) score = 1; // vendor-specific next
|
|
192
|
+
candidates.push({ iface, altIndex, alt, score });
|
|
193
|
+
break; // Found suitable alternate for this interface
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!candidates.length) {
|
|
199
|
+
throw new Error('No suitable USB interface found');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
candidates.sort((a, b) => a.score - b.score);
|
|
203
|
+
let lastErr = null;
|
|
204
|
+
for (const cand of candidates) {
|
|
205
|
+
try {
|
|
206
|
+
// CORRECT ORDER per WebUSB spec: claimInterface FIRST, then selectAlternateInterface
|
|
207
|
+
await this.device.claimInterface(cand.iface.interfaceNumber);
|
|
208
|
+
try {
|
|
209
|
+
await this.device.selectAlternateInterface(cand.iface.interfaceNumber, cand.altIndex);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
this._log(`[WebUSB] selectAlternateInterface failed: ${e.message}`);
|
|
212
|
+
// If we can't select a non-default alternate, endpoints may not match; try next candidate.
|
|
213
|
+
if (cand.altIndex !== 0) {
|
|
214
|
+
try { await this.device.releaseInterface(cand.iface.interfaceNumber); } catch (_) {}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
this.interfaceNumber = cand.iface.interfaceNumber;
|
|
219
|
+
|
|
220
|
+
// Use the alternate that was found to have bulk endpoints
|
|
221
|
+
for (const ep of cand.alt.endpoints) {
|
|
222
|
+
if (ep.type === 'bulk' && ep.direction === 'in') {
|
|
223
|
+
this.endpointIn = ep.endpointNumber;
|
|
224
|
+
} else if (ep.type === 'bulk' && ep.direction === 'out') {
|
|
225
|
+
this.endpointOut = ep.endpointNumber;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Validate that both endpoints were found
|
|
230
|
+
if (this.endpointIn == null || this.endpointOut == null) {
|
|
231
|
+
throw new Error(`Missing bulk endpoints (in=${this.endpointIn}, out=${this.endpointOut})`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Use endpoint packet size for transfer length (Android prefers max-packet)
|
|
235
|
+
try {
|
|
236
|
+
const inEp = cand.alt.endpoints.find(ep => ep.type === 'bulk' && ep.direction === 'in');
|
|
237
|
+
if (inEp && inEp.packetSize) {
|
|
238
|
+
// Don't limit by packetSize - use our optimized value
|
|
239
|
+
} else {
|
|
240
|
+
this._log(`[WebUSB] No packetSize found, keeping maxTransferSize=${this.maxTransferSize}`);
|
|
241
|
+
}
|
|
242
|
+
} catch (e) {
|
|
243
|
+
// Suppress packetSize check error - not critical
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return config;
|
|
247
|
+
} catch (claimErr) {
|
|
248
|
+
lastErr = claimErr;
|
|
249
|
+
// Suppress claim failed message - this is expected when trying multiple interfaces
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
throw lastErr || new Error('Unable to claim any USB interface');
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
let config;
|
|
257
|
+
try {
|
|
258
|
+
config = await attemptOpenAndClaim();
|
|
259
|
+
} catch (err) {
|
|
260
|
+
this._log('[WebUSB] open/claim failed, retrying after reset:', err.message);
|
|
261
|
+
try { if (this.device.reset) { await this.device.reset(); } } catch (e) { }
|
|
262
|
+
try { await this.device.close(); } catch (e) { }
|
|
263
|
+
try {
|
|
264
|
+
config = await attemptOpenAndClaim();
|
|
265
|
+
} catch (err2) {
|
|
266
|
+
throw new Error(`Unable to claim USB interface: ${err2.message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Claim control interface if not already claimed
|
|
271
|
+
if (this.controlInterface == null) {
|
|
272
|
+
const controlIface = config.interfaces.find(i =>
|
|
273
|
+
i.alternates[0].interfaceClass === 0x02 &&
|
|
274
|
+
i.interfaceNumber !== this.interfaceNumber
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (controlIface) {
|
|
278
|
+
try {
|
|
279
|
+
await this.device.claimInterface(controlIface.interfaceNumber);
|
|
280
|
+
try { await this.device.selectAlternateInterface(controlIface.interfaceNumber, 0); } catch (e) { }
|
|
281
|
+
this.controlInterface = controlIface.interfaceNumber;
|
|
282
|
+
} catch (e) {
|
|
283
|
+
this.controlInterface = this.interfaceNumber;
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
this.controlInterface = this.interfaceNumber;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// CP2102-specific initialization sequence (must be in this exact order!)
|
|
291
|
+
if (this.device.vendorId === 0x10c4) {
|
|
292
|
+
try {
|
|
293
|
+
// Step 1: Enable UART interface
|
|
294
|
+
await this.device.controlTransferOut({
|
|
295
|
+
requestType: 'vendor',
|
|
296
|
+
recipient: 'device',
|
|
297
|
+
request: 0x00, // IFC_ENABLE
|
|
298
|
+
value: 0x01, // UART_ENABLE
|
|
299
|
+
index: 0x00
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Step 2: Set line control (8N1: 8 data bits, no parity, 1 stop bit)
|
|
303
|
+
await this.device.controlTransferOut({
|
|
304
|
+
requestType: 'vendor',
|
|
305
|
+
recipient: 'device',
|
|
306
|
+
request: 0x03, // SET_LINE_CTL
|
|
307
|
+
value: 0x0800, // 8 data bits, no parity, 1 stop bit
|
|
308
|
+
index: 0x00
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Step 3: Set DTR/RTS signals (vendor-specific for CP2102)
|
|
312
|
+
await this.device.controlTransferOut({
|
|
313
|
+
requestType: 'vendor',
|
|
314
|
+
recipient: 'device',
|
|
315
|
+
request: 0x07, // SET_MHS
|
|
316
|
+
value: 0x03 | 0x0100 | 0x0200, // DTR=1, RTS=1 with masks
|
|
317
|
+
index: 0x00
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Step 4: Set baudrate (vendor-specific for CP2102)
|
|
321
|
+
// Use IFC_SET_BAUDRATE (0x1E) with direct 32-bit baudrate value
|
|
322
|
+
const baudrateBuffer = new ArrayBuffer(4);
|
|
323
|
+
const baudrateView = new DataView(baudrateBuffer);
|
|
324
|
+
baudrateView.setUint32(0, baudRate, true); // little-endian
|
|
325
|
+
|
|
326
|
+
await this.device.controlTransferOut({
|
|
327
|
+
requestType: 'vendor',
|
|
328
|
+
recipient: 'interface',
|
|
329
|
+
request: 0x1E, // IFC_SET_BAUDRATE
|
|
330
|
+
value: 0,
|
|
331
|
+
index: 0
|
|
332
|
+
}, baudrateBuffer);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
this._log('[WebUSB CP2102] Initialization error:', e.message);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// FTDI-specific initialization sequence
|
|
338
|
+
else if (this.device.vendorId === 0x0403) {
|
|
339
|
+
try {
|
|
340
|
+
// Step 1: Reset device
|
|
341
|
+
await this.device.controlTransferOut({
|
|
342
|
+
requestType: 'vendor',
|
|
343
|
+
recipient: 'device',
|
|
344
|
+
request: 0x00, // SIO_RESET
|
|
345
|
+
value: 0x00, // Reset
|
|
346
|
+
index: 0x00
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Step 2: Set flow control to none
|
|
350
|
+
await this.device.controlTransferOut({
|
|
351
|
+
requestType: 'vendor',
|
|
352
|
+
recipient: 'device',
|
|
353
|
+
request: 0x02, // SIO_SET_FLOW_CTRL
|
|
354
|
+
value: 0x00, // No flow control
|
|
355
|
+
index: 0x00
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Step 3: Set data characteristics (8N1)
|
|
359
|
+
await this.device.controlTransferOut({
|
|
360
|
+
requestType: 'vendor',
|
|
361
|
+
recipient: 'device',
|
|
362
|
+
request: 0x04, // SIO_SET_DATA
|
|
363
|
+
value: 0x0008, // 8 data bits, no parity, 1 stop bit
|
|
364
|
+
index: 0x00
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Step 4: Set baudrate
|
|
368
|
+
const baseClock = 3000000; // 48MHz / 16
|
|
369
|
+
let divisor = baseClock / baudRate;
|
|
370
|
+
const integerPart = Math.floor(divisor);
|
|
371
|
+
const fractionalPart = divisor - integerPart;
|
|
372
|
+
|
|
373
|
+
let subInteger;
|
|
374
|
+
if (fractionalPart < 0.0625) subInteger = 0;
|
|
375
|
+
else if (fractionalPart < 0.1875) subInteger = 1;
|
|
376
|
+
else if (fractionalPart < 0.3125) subInteger = 2;
|
|
377
|
+
else if (fractionalPart < 0.4375) subInteger = 3;
|
|
378
|
+
else if (fractionalPart < 0.5625) subInteger = 4;
|
|
379
|
+
else if (fractionalPart < 0.6875) subInteger = 5;
|
|
380
|
+
else if (fractionalPart < 0.8125) subInteger = 6;
|
|
381
|
+
else subInteger = 7;
|
|
382
|
+
|
|
383
|
+
const value = (integerPart & 0xFF) | ((subInteger & 0x07) << 14) | (((integerPart >> 8) & 0x3F) << 8);
|
|
384
|
+
const index = (integerPart >> 14) & 0x03;
|
|
385
|
+
|
|
386
|
+
await this.device.controlTransferOut({
|
|
387
|
+
requestType: 'vendor',
|
|
388
|
+
recipient: 'device',
|
|
389
|
+
request: 0x03, // SIO_SET_BAUD_RATE
|
|
390
|
+
value: value,
|
|
391
|
+
index: index
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Step 5: Set DTR/RTS (modem control)
|
|
395
|
+
await this.device.controlTransferOut({
|
|
396
|
+
requestType: 'vendor',
|
|
397
|
+
recipient: 'device',
|
|
398
|
+
request: 0x01, // SIO_MODEM_CTRL
|
|
399
|
+
value: 0x0303, // DTR=1, RTS=1
|
|
400
|
+
index: 0x00
|
|
401
|
+
});
|
|
402
|
+
} catch (e) {
|
|
403
|
+
this._log('[WebUSB FTDI] Initialization error:', e.message);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// CH340-specific initialization (VID: 0x1a86, but not CH343 PID: 0x55d3)
|
|
407
|
+
else if (this.device.vendorId === 0x1a86 && this.device.productId !== 0x55d3) {
|
|
408
|
+
try {
|
|
409
|
+
// Step 1: Initialize CH340
|
|
410
|
+
await this.device.controlTransferOut({
|
|
411
|
+
requestType: 'vendor',
|
|
412
|
+
recipient: 'device',
|
|
413
|
+
request: 0xA1, // CH340 INIT
|
|
414
|
+
value: 0x0000,
|
|
415
|
+
index: 0x0000
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Step 2: Set baudrate
|
|
419
|
+
const CH341_BAUDBASE_FACTOR = 1532620800;
|
|
420
|
+
const CH341_BAUDBASE_DIVMAX = 3;
|
|
421
|
+
|
|
422
|
+
let factor = Math.floor(CH341_BAUDBASE_FACTOR / baudRate);
|
|
423
|
+
let divisor = CH341_BAUDBASE_DIVMAX;
|
|
424
|
+
|
|
425
|
+
while (factor > 0xfff0 && divisor > 0) {
|
|
426
|
+
factor >>= 3;
|
|
427
|
+
divisor--;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (factor > 0xfff0) {
|
|
431
|
+
throw new Error(`Baudrate ${baudRate} not supported by CH340`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
factor = 0x10000 - factor;
|
|
435
|
+
const a = (factor & 0xff00) | divisor;
|
|
436
|
+
const b = factor & 0xff;
|
|
437
|
+
|
|
438
|
+
await this.device.controlTransferOut({
|
|
439
|
+
requestType: 'vendor',
|
|
440
|
+
recipient: 'device',
|
|
441
|
+
request: 0x9A,
|
|
442
|
+
value: 0x1312,
|
|
443
|
+
index: a
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await this.device.controlTransferOut({
|
|
447
|
+
requestType: 'vendor',
|
|
448
|
+
recipient: 'device',
|
|
449
|
+
request: 0x9A,
|
|
450
|
+
value: 0x0f2c,
|
|
451
|
+
index: b
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// Step 3: Set handshake (DTR/RTS)
|
|
455
|
+
await this.device.controlTransferOut({
|
|
456
|
+
requestType: 'vendor',
|
|
457
|
+
recipient: 'device',
|
|
458
|
+
request: 0xA4, // CH340 SET_HANDSHAKE
|
|
459
|
+
value: (~((1 << 5) | (1 << 6))) & 0xffff, // DTR=1, RTS=1 (inverted), masked to 16-bit
|
|
460
|
+
index: 0x0000
|
|
461
|
+
});
|
|
462
|
+
} catch (e) {
|
|
463
|
+
this._log('[WebUSB CH340] Initialization error:', e.message);
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
// Standard CDC/ACM initialization for other chips
|
|
467
|
+
try {
|
|
468
|
+
const lineCoding = new Uint8Array([
|
|
469
|
+
baudRate & 0xFF,
|
|
470
|
+
(baudRate >> 8) & 0xFF,
|
|
471
|
+
(baudRate >> 16) & 0xFF,
|
|
472
|
+
(baudRate >> 24) & 0xFF,
|
|
473
|
+
0x00, // 1 stop bit
|
|
474
|
+
0x00, // No parity
|
|
475
|
+
0x08 // 8 data bits
|
|
476
|
+
]);
|
|
477
|
+
|
|
478
|
+
await this.device.controlTransferOut({
|
|
479
|
+
requestType: 'class',
|
|
480
|
+
recipient: 'interface',
|
|
481
|
+
request: 0x20, // SET_LINE_CODING
|
|
482
|
+
value: 0,
|
|
483
|
+
index: this.controlInterface || 0
|
|
484
|
+
}, lineCoding);
|
|
485
|
+
} catch (e) {
|
|
486
|
+
this._log('Could not set line coding:', e.message);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Initialize DTR/RTS to idle state (both HIGH/asserted)
|
|
490
|
+
try {
|
|
491
|
+
await this.device.controlTransferOut({
|
|
492
|
+
requestType: 'class',
|
|
493
|
+
recipient: 'interface',
|
|
494
|
+
request: 0x22, // SET_CONTROL_LINE_STATE
|
|
495
|
+
value: 0x03, // DTR=1, RTS=1 (both asserted)
|
|
496
|
+
index: this.controlInterface || 0
|
|
497
|
+
});
|
|
498
|
+
} catch (e) {
|
|
499
|
+
this._log('Could not set control lines:', e.message);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Create streams only if they don't exist yet
|
|
504
|
+
if (!this.readableStream || !this.writableStream) {
|
|
505
|
+
this._createStreams();
|
|
506
|
+
} else {
|
|
507
|
+
// Streams exist, but make sure read loop is running
|
|
508
|
+
if (!this._readLoopRunning) {
|
|
509
|
+
this._readLoopRunning = true;
|
|
510
|
+
// Note: ReadableStream can't be restarted, we need to recreate it
|
|
511
|
+
this._createStreams();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Setup disconnect handler only once
|
|
516
|
+
if (!this._usbDisconnectHandler) {
|
|
517
|
+
this._usbDisconnectHandler = (event) => {
|
|
518
|
+
if (event.device === this.device) {
|
|
519
|
+
this._fireEvent('disconnect');
|
|
520
|
+
this._cleanup();
|
|
521
|
+
// Mark instance unusable until a new requestPort/open cycle
|
|
522
|
+
this.device = null;
|
|
523
|
+
this.interfaceNumber = null;
|
|
524
|
+
this.controlInterface = null;
|
|
525
|
+
this.endpointIn = null;
|
|
526
|
+
this.endpointOut = null;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
navigator.usb.addEventListener('disconnect', this._usbDisconnectHandler);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Close the device (mimics port.close())
|
|
535
|
+
*/
|
|
536
|
+
async close() {
|
|
537
|
+
this._cleanup();
|
|
538
|
+
if (this.device) {
|
|
539
|
+
try {
|
|
540
|
+
if (this.interfaceNumber !== null) {
|
|
541
|
+
await this.device.releaseInterface(this.interfaceNumber);
|
|
542
|
+
}
|
|
543
|
+
if (this.controlInterface !== null && this.controlInterface !== this.interfaceNumber) {
|
|
544
|
+
await this.device.releaseInterface(this.controlInterface);
|
|
545
|
+
}
|
|
546
|
+
await this.device.close();
|
|
547
|
+
} catch (e) {
|
|
548
|
+
if (!e.message || !e.message.includes('disconnected')) {
|
|
549
|
+
this._log('Error closing device:', e.message || e);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// Keep device reference for potential reconfiguration
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Disconnect and clear device reference (for final cleanup)
|
|
558
|
+
*/
|
|
559
|
+
async disconnect() {
|
|
560
|
+
await this.close();
|
|
561
|
+
this.device = null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Get optimal block size for flash read operations
|
|
566
|
+
* (maxTransferSize - 2) / 2
|
|
567
|
+
* This accounts for SLIP overhead and escape sequences
|
|
568
|
+
* @returns {number} Optimal block size in bytes
|
|
569
|
+
*/
|
|
570
|
+
getOptimalReadBlockSize() {
|
|
571
|
+
// Formula for WebUSB:
|
|
572
|
+
// blockSize = (maxTransferSize - 2) / 2
|
|
573
|
+
// -2 for SLIP frame delimiters (0xC0 at start/end)
|
|
574
|
+
// /2 because worst case every byte could be escaped (0xDB 0xDC or 0xDB 0xDD)
|
|
575
|
+
return Math.floor((this.maxTransferSize - 2) / 2);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Get device info (mimics port.getInfo())
|
|
580
|
+
*/
|
|
581
|
+
getInfo() {
|
|
582
|
+
if (!this.device) {
|
|
583
|
+
return {};
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
usbVendorId: this.device.vendorId,
|
|
587
|
+
usbProductId: this.device.productId
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Set DTR/RTS signals (mimics port.setSignals())
|
|
593
|
+
* CRITICAL: Commands are serialized via queue for CP2102 compatibility
|
|
594
|
+
* Supports both CDC/ACM (CH343) and Vendor-Specific (CP2102, CH340)
|
|
595
|
+
*/
|
|
596
|
+
async setSignals(signals) {
|
|
597
|
+
// Serialize all control transfers through a queue
|
|
598
|
+
// This is CRITICAL for CP2102 on Android - parallel commands cause hangs
|
|
599
|
+
this._commandQueue = this._commandQueue.then(async () => {
|
|
600
|
+
if (!this.device) {
|
|
601
|
+
throw new Error('Device not open');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const vid = this.device.vendorId;
|
|
605
|
+
const pid = this.device.productId;
|
|
606
|
+
|
|
607
|
+
// Detect chip type and use appropriate control request
|
|
608
|
+
// CP2102 (Silicon Labs VID: 0x10c4)
|
|
609
|
+
if (vid === 0x10c4) {
|
|
610
|
+
return await this._setSignalsCP2102(signals);
|
|
611
|
+
}
|
|
612
|
+
// CH340 (WCH VID: 0x1a86, but not CH343 PID: 0x55d3)
|
|
613
|
+
else if (vid === 0x1a86 && pid !== 0x55d3) {
|
|
614
|
+
return await this._setSignalsCH340(signals);
|
|
615
|
+
}
|
|
616
|
+
// CDC/ACM (CH343, Native USB, etc.)
|
|
617
|
+
else {
|
|
618
|
+
return await this._setSignalsCDC(signals);
|
|
619
|
+
}
|
|
620
|
+
}).catch(err => {
|
|
621
|
+
this._log('[WebUSB] setSignals error:', err);
|
|
622
|
+
throw err;
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
return this._commandQueue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Set signals using CDC/ACM standard (for CH343, Native USB)
|
|
630
|
+
*/
|
|
631
|
+
async _setSignalsCDC(signals) {
|
|
632
|
+
// Preserve current state for unspecified signals (Web Serial semantics)
|
|
633
|
+
const dtr = signals.dataTerminalReady !== undefined ? signals.dataTerminalReady : this._currentDTR;
|
|
634
|
+
const rts = signals.requestToSend !== undefined ? signals.requestToSend : this._currentRTS;
|
|
635
|
+
|
|
636
|
+
// Update tracked state
|
|
637
|
+
this._currentDTR = dtr;
|
|
638
|
+
this._currentRTS = rts;
|
|
639
|
+
|
|
640
|
+
let value = 0;
|
|
641
|
+
value |= dtr ? 1 : 0;
|
|
642
|
+
value |= rts ? 2 : 0;
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
const result = await this.device.controlTransferOut({
|
|
646
|
+
requestType: 'class',
|
|
647
|
+
recipient: 'interface',
|
|
648
|
+
request: 0x22, // SET_CONTROL_LINE_STATE
|
|
649
|
+
value: value,
|
|
650
|
+
index: this.controlInterface || 0
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
654
|
+
return result;
|
|
655
|
+
} catch (e) {
|
|
656
|
+
this._log(`[WebUSB CDC] Failed to set signals: ${e.message}`);
|
|
657
|
+
throw e;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Set signals for CP2102 (Silicon Labs vendor-specific)
|
|
663
|
+
*/
|
|
664
|
+
async _setSignalsCP2102(signals) {
|
|
665
|
+
// CP2102 uses vendor-specific request 0x07 (SET_MHS)
|
|
666
|
+
// Bit 0: DTR, Bit 1: RTS, Bit 8-9: DTR/RTS mask
|
|
667
|
+
|
|
668
|
+
// Preserve current state for unspecified signals (Web Serial semantics)
|
|
669
|
+
const dtr = signals.dataTerminalReady !== undefined ? signals.dataTerminalReady : this._currentDTR;
|
|
670
|
+
const rts = signals.requestToSend !== undefined ? signals.requestToSend : this._currentRTS;
|
|
671
|
+
|
|
672
|
+
// Update tracked state
|
|
673
|
+
this._currentDTR = dtr;
|
|
674
|
+
this._currentRTS = rts;
|
|
675
|
+
|
|
676
|
+
// Build value with mask bits for both signals
|
|
677
|
+
let value = 0;
|
|
678
|
+
value |= (dtr ? 1 : 0) | 0x100; // DTR + mask
|
|
679
|
+
value |= (rts ? 2 : 0) | 0x200; // RTS + mask
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const result = await this.device.controlTransferOut({
|
|
683
|
+
requestType: 'vendor',
|
|
684
|
+
recipient: 'device',
|
|
685
|
+
request: 0x07, // SET_MHS (Modem Handshaking)
|
|
686
|
+
value: value,
|
|
687
|
+
index: 0x00 // CP2102 always uses index 0
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
691
|
+
return result;
|
|
692
|
+
} catch (e) {
|
|
693
|
+
this._log(`[WebUSB CP2102] Failed to set signals: ${e.message}`);
|
|
694
|
+
throw e;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Set signals for CH340 (WCH vendor-specific)
|
|
700
|
+
*/
|
|
701
|
+
async _setSignalsCH340(signals) {
|
|
702
|
+
// Preserve current state for unspecified signals (Web Serial semantics)
|
|
703
|
+
const dtr = signals.dataTerminalReady !== undefined ? signals.dataTerminalReady : this._currentDTR;
|
|
704
|
+
const rts = signals.requestToSend !== undefined ? signals.requestToSend : this._currentRTS;
|
|
705
|
+
|
|
706
|
+
// Update tracked state
|
|
707
|
+
this._currentDTR = dtr;
|
|
708
|
+
this._currentRTS = rts;
|
|
709
|
+
|
|
710
|
+
// CH340 uses vendor-specific request 0xA4
|
|
711
|
+
// Bit 5: DTR, Bit 6: RTS (inverted logic!)
|
|
712
|
+
// Calculate value with bitwise NOT and mask to unsigned 16-bit
|
|
713
|
+
const value = (~((dtr ? 1 << 5 : 0) | (rts ? 1 << 6 : 0))) & 0xffff;
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
const result = await this.device.controlTransferOut({
|
|
717
|
+
requestType: 'vendor',
|
|
718
|
+
recipient: 'device',
|
|
719
|
+
request: 0xA4, // CH340 control request
|
|
720
|
+
value: value,
|
|
721
|
+
index: 0
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
725
|
+
return result;
|
|
726
|
+
} catch (e) {
|
|
727
|
+
this._log(`[WebUSB CH340] Failed to set signals: ${e.message}`);
|
|
728
|
+
throw e;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Change baudrate after port is already open
|
|
734
|
+
* This is needed for ESP stub loader which changes baudrate after uploading stub
|
|
735
|
+
* NOTE: Only needed for vendor-specific chips (CP2102, CH340, FTDI)
|
|
736
|
+
* CDC devices (CH343, ESP32-S2/S3/C3 Native USB) handle baudrate automatically
|
|
737
|
+
*/
|
|
738
|
+
async setBaudRate(baudRate) {
|
|
739
|
+
if (!this.device) {
|
|
740
|
+
throw new Error('Device not open');
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const vid = this.device.vendorId;
|
|
744
|
+
const pid = this.device.productId;
|
|
745
|
+
|
|
746
|
+
// this._log(`[WebUSB] Changing baudrate to ${baudRate}...`);
|
|
747
|
+
|
|
748
|
+
// FTDI (VID: 0x0403)
|
|
749
|
+
if (vid === 0x0403) {
|
|
750
|
+
// FTDI baudrate calculation
|
|
751
|
+
// Modern FTDI chips (FT232R, FT2232, etc.): BaseClock = 48MHz
|
|
752
|
+
// BaudDivisor = (48000000 / 16) / BaudRate = 3000000 / BaudRate
|
|
753
|
+
// Divisor encoding: 16-bit value with sub-integer divisor support
|
|
754
|
+
// Sub-integer divisor: 0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875
|
|
755
|
+
|
|
756
|
+
const baseClock = 3000000; // 48MHz / 16
|
|
757
|
+
let divisor = baseClock / baudRate;
|
|
758
|
+
|
|
759
|
+
// Extract integer and fractional parts
|
|
760
|
+
const integerPart = Math.floor(divisor);
|
|
761
|
+
const fractionalPart = divisor - integerPart;
|
|
762
|
+
|
|
763
|
+
// Encode sub-integer divisor (0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875)
|
|
764
|
+
let subInteger;
|
|
765
|
+
if (fractionalPart < 0.0625) subInteger = 0; // 0.0
|
|
766
|
+
else if (fractionalPart < 0.1875) subInteger = 1; // 0.125
|
|
767
|
+
else if (fractionalPart < 0.3125) subInteger = 2; // 0.25
|
|
768
|
+
else if (fractionalPart < 0.4375) subInteger = 3; // 0.375
|
|
769
|
+
else if (fractionalPart < 0.5625) subInteger = 4; // 0.5
|
|
770
|
+
else if (fractionalPart < 0.6875) subInteger = 5; // 0.625
|
|
771
|
+
else if (fractionalPart < 0.8125) subInteger = 6; // 0.75
|
|
772
|
+
else subInteger = 7; // 0.875
|
|
773
|
+
|
|
774
|
+
// Encode divisor value for FTDI
|
|
775
|
+
// Low byte: integer part (bits 0-7)
|
|
776
|
+
// High byte: (integer part >> 8) | (sub-integer << 6)
|
|
777
|
+
const value = (integerPart & 0xFF) | ((subInteger & 0x07) << 14) | (((integerPart >> 8) & 0x3F) << 8);
|
|
778
|
+
const index = (integerPart >> 14) & 0x03; // Upper 2 bits of integer part
|
|
779
|
+
|
|
780
|
+
// this._log(`[WebUSB FTDI] Setting baudrate ${baudRate} (divisor=${divisor.toFixed(3)}, value=0x${value.toString(16)}, index=0x${index.toString(16)})...`);
|
|
781
|
+
|
|
782
|
+
await this.device.controlTransferOut({
|
|
783
|
+
requestType: 'vendor',
|
|
784
|
+
recipient: 'device',
|
|
785
|
+
request: 0x03, // SIO_SET_BAUD_RATE
|
|
786
|
+
value: value,
|
|
787
|
+
index: index
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// this._log('[WebUSB FTDI] Baudrate changed successfully');
|
|
791
|
+
}
|
|
792
|
+
// CP2102 (Silicon Labs VID: 0x10c4)
|
|
793
|
+
else if (vid === 0x10c4) {
|
|
794
|
+
// CP210x baudrate encoding (from Silicon Labs AN571)
|
|
795
|
+
// For CP2102/CP2103: Use direct 32-bit baudrate value
|
|
796
|
+
// Request: IFC_SET_BAUDRATE (0x1E)
|
|
797
|
+
|
|
798
|
+
// Encode baudrate as 32-bit little-endian value
|
|
799
|
+
const baudrateBuffer = new ArrayBuffer(4);
|
|
800
|
+
const baudrateView = new DataView(baudrateBuffer);
|
|
801
|
+
baudrateView.setUint32(0, baudRate, true); // little-endian
|
|
802
|
+
|
|
803
|
+
await this.device.controlTransferOut({
|
|
804
|
+
requestType: 'vendor',
|
|
805
|
+
recipient: 'interface',
|
|
806
|
+
request: 0x1E, // IFC_SET_BAUDRATE
|
|
807
|
+
value: 0,
|
|
808
|
+
index: 0
|
|
809
|
+
}, baudrateBuffer);
|
|
810
|
+
}
|
|
811
|
+
// CH340 (WCH VID: 0x1a86, but not CH343 PID: 0x55d3)
|
|
812
|
+
else if (vid === 0x1a86 && pid !== 0x55d3) {
|
|
813
|
+
// CH340 baudrate calculation (from Linux kernel driver)
|
|
814
|
+
// CH341_BAUDBASE_FACTOR = 1532620800
|
|
815
|
+
// CH341_BAUDBASE_DIVMAX = 3
|
|
816
|
+
const CH341_BAUDBASE_FACTOR = 1532620800;
|
|
817
|
+
const CH341_BAUDBASE_DIVMAX = 3;
|
|
818
|
+
|
|
819
|
+
let factor = Math.floor(CH341_BAUDBASE_FACTOR / baudRate);
|
|
820
|
+
let divisor = CH341_BAUDBASE_DIVMAX;
|
|
821
|
+
|
|
822
|
+
// Reduce factor if too large
|
|
823
|
+
while (factor > 0xfff0 && divisor > 0) {
|
|
824
|
+
factor >>= 3;
|
|
825
|
+
divisor--;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (factor > 0xfff0) {
|
|
829
|
+
throw new Error(`Baudrate ${baudRate} not supported by CH340`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
factor = 0x10000 - factor;
|
|
833
|
+
const a = (factor & 0xff00) | divisor;
|
|
834
|
+
const b = factor & 0xff;
|
|
835
|
+
|
|
836
|
+
// CH340 uses request 0x9A to set baudrate
|
|
837
|
+
await this.device.controlTransferOut({
|
|
838
|
+
requestType: 'vendor',
|
|
839
|
+
recipient: 'device',
|
|
840
|
+
request: 0x9A, // CH340 SET_BAUDRATE
|
|
841
|
+
value: 0x1312, // Fixed value for baudrate setting
|
|
842
|
+
index: a
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
// Second control transfer with b value
|
|
846
|
+
await this.device.controlTransferOut({
|
|
847
|
+
requestType: 'vendor',
|
|
848
|
+
recipient: 'device',
|
|
849
|
+
request: 0x9A,
|
|
850
|
+
value: 0x0f2c, // Fixed value
|
|
851
|
+
index: b
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
}
|
|
855
|
+
// CDC devices (CH343, ESP32 Native USB) - no action needed in setBaudRate()
|
|
856
|
+
// They are handled by close/reopen in esp_loader.ts
|
|
857
|
+
|
|
858
|
+
// Wait for baudrate change to take effect
|
|
859
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
get readable() {
|
|
863
|
+
return this.readableStream;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
get writable() {
|
|
867
|
+
return this.writableStream;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
_createStreams() {
|
|
871
|
+
// ReadableStream for incoming data
|
|
872
|
+
this.readableStream = new ReadableStream({
|
|
873
|
+
start: async (controller) => {
|
|
874
|
+
this._readLoopRunning = true;
|
|
875
|
+
let streamErrored = false;
|
|
876
|
+
|
|
877
|
+
// Validate endpoints before starting read loop
|
|
878
|
+
if (this.endpointIn == null) {
|
|
879
|
+
controller.error(new Error('Bulk IN endpoint not configured'));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
try {
|
|
884
|
+
while (this._readLoopRunning && this.device) {
|
|
885
|
+
try {
|
|
886
|
+
const result = await this.device.transferIn(this.endpointIn, this.maxTransferSize);
|
|
887
|
+
|
|
888
|
+
if (result.status === 'ok') {
|
|
889
|
+
controller.enqueue(new Uint8Array(result.data.buffer, result.data.byteOffset, result.data.byteLength));
|
|
890
|
+
// No delay - immediately read next packet
|
|
891
|
+
continue;
|
|
892
|
+
} else if (result.status === 'stall') {
|
|
893
|
+
await this.device.clearHalt('in', this.endpointIn);
|
|
894
|
+
await new Promise(r => setTimeout(r, 1));
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
// Only wait if no data was received
|
|
898
|
+
await new Promise(r => setTimeout(r, 1));
|
|
899
|
+
} catch (error) {
|
|
900
|
+
if (error.message && (error.message.includes('device unavailable') ||
|
|
901
|
+
error.message.includes('device has been lost') ||
|
|
902
|
+
error.message.includes('device was disconnected') ||
|
|
903
|
+
error.message.includes('No device selected'))) {
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
if (error.message && (error.message.includes('transfer was cancelled') ||
|
|
907
|
+
error.message.includes('transfer error has occurred'))) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
this._log('USB read error:', error.message);
|
|
911
|
+
// Wait a bit after error before retrying
|
|
912
|
+
await new Promise(r => setTimeout(r, 10));
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
} catch (error) {
|
|
916
|
+
streamErrored = true;
|
|
917
|
+
controller.error(error);
|
|
918
|
+
} finally {
|
|
919
|
+
// Only close if stream didn't error
|
|
920
|
+
if (!streamErrored) {
|
|
921
|
+
controller.close();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
cancel: () => {
|
|
926
|
+
this._readLoopRunning = false;
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
// WritableStream for outgoing data
|
|
931
|
+
this.writableStream = new WritableStream({
|
|
932
|
+
write: async (chunk) => {
|
|
933
|
+
if (!this.device) {
|
|
934
|
+
throw new Error('Device not open');
|
|
935
|
+
}
|
|
936
|
+
if (this.endpointOut == null) {
|
|
937
|
+
throw new Error('Bulk OUT endpoint not configured');
|
|
938
|
+
}
|
|
939
|
+
await this.device.transferOut(this.endpointOut, chunk);
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
_cleanup() {
|
|
945
|
+
this._readLoopRunning = false;
|
|
946
|
+
if (this._usbDisconnectHandler) {
|
|
947
|
+
navigator.usb.removeEventListener('disconnect', this._usbDisconnectHandler);
|
|
948
|
+
this._usbDisconnectHandler = null;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
_fireEvent(type) {
|
|
953
|
+
const listeners = this._eventListeners[type] || [];
|
|
954
|
+
listeners.forEach(listener => {
|
|
955
|
+
try {
|
|
956
|
+
listener();
|
|
957
|
+
} catch (e) {
|
|
958
|
+
this._log(`Error in ${type} event listener:`, e);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
addEventListener(type, listener) {
|
|
964
|
+
if (this._eventListeners[type]) {
|
|
965
|
+
this._eventListeners[type].push(listener);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
removeEventListener(type, listener) {
|
|
970
|
+
if (this._eventListeners[type]) {
|
|
971
|
+
const index = this._eventListeners[type].indexOf(listener);
|
|
972
|
+
if (index !== -1) {
|
|
973
|
+
this._eventListeners[type].splice(index, 1);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Selects and returns a serial port using the most appropriate browser API for the current platform.
|
|
981
|
+
*
|
|
982
|
+
* Attempts WebUSB on Android (where Web Serial is unreliable) and prefers the Web Serial API on desktop,
|
|
983
|
+
* falling back between APIs as needed.
|
|
984
|
+
* @param {boolean} forceNew - When true, prefer requesting a new device (ignore previously authorized devices) for WebUSB.
|
|
985
|
+
* @returns {SerialPort|WebUSBSerial} A serial port obtained from the Web Serial API or a WebUSBSerial instance wrapping a USB device.
|
|
986
|
+
*/
|
|
987
|
+
async function requestSerialPort(forceNew = false) {
|
|
988
|
+
// Detect if we're on Android
|
|
989
|
+
const isAndroid = /Android/i.test(navigator.userAgent);
|
|
990
|
+
const hasSerial = 'serial' in navigator;
|
|
991
|
+
const hasUSB = 'usb' in navigator;
|
|
992
|
+
|
|
993
|
+
console.log(`[requestSerialPort] Platform: ${isAndroid ? 'Android' : 'Desktop'}, Web Serial: ${hasSerial}, WebUSB: ${hasUSB}`);
|
|
994
|
+
|
|
995
|
+
// On Android, prefer WebUSB (Web Serial doesn't work properly)
|
|
996
|
+
if (isAndroid && hasUSB) {
|
|
997
|
+
try {
|
|
998
|
+
return await WebUSBSerial.requestPort(null, forceNew);
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
console.log('WebUSB failed, trying Web Serial...', err.message);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Try Web Serial API (preferred on desktop)
|
|
1005
|
+
if (hasSerial) {
|
|
1006
|
+
try {
|
|
1007
|
+
// Web Serial API doesn't support device reuse in the same way
|
|
1008
|
+
// It always shows the picker, but the browser remembers permissions
|
|
1009
|
+
return await navigator.serial.requestPort();
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
console.log('Web Serial not available or cancelled, trying WebUSB...');
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Fall back to WebUSB
|
|
1016
|
+
if (hasUSB) {
|
|
1017
|
+
try {
|
|
1018
|
+
return await WebUSBSerial.requestPort(null, forceNew);
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
throw new Error('Neither Web Serial nor WebUSB available or user cancelled');
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
throw new Error('Neither Web Serial API nor WebUSB is supported in this browser');
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Export as ES modules
|
|
1028
|
+
export { WebUSBSerial, requestSerialPort };
|