tasmota-webserial-esptool 7.3.4 → 9.1.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.
@@ -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 };