esp32tool 1.1.8 → 1.2.0

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