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
package/src/esp_loader.ts CHANGED
@@ -42,6 +42,7 @@ import {
42
42
  USB_RAM_BLOCK,
43
43
  ChipFamily,
44
44
  ESP_ERASE_FLASH,
45
+ ESP_ERASE_REGION,
45
46
  ESP_READ_FLASH,
46
47
  CHIP_ERASE_TIMEOUT,
47
48
  FLASH_READ_TIMEOUT,
@@ -62,15 +63,25 @@ import {
62
63
  } from "./const";
63
64
  import { getStubCode } from "./stubs";
64
65
  import { hexFormatter, sleep, slipEncode, toHex } from "./util";
65
- // @ts-expect-error pako ESM module doesn't have proper type definitions
66
- import { deflate } from "pako/dist/pako.esm.mjs";
66
+ import { deflate } from "pako";
67
67
  import { pack, unpack } from "./struct";
68
68
 
69
+ // Interface for WebUSB Serial Port (extends SerialPort with WebUSB-specific methods)
70
+ interface WebUSBSerialPort extends SerialPort {
71
+ isWebUSB?: boolean;
72
+ maxTransferSize?: number;
73
+ setSignals(signals: {
74
+ dataTerminalReady?: boolean;
75
+ requestToSend?: boolean;
76
+ }): Promise<void>;
77
+ setBaudRate(baudRate: number): Promise<void>;
78
+ }
79
+
69
80
  export class ESPLoader extends EventTarget {
70
- chipFamily!: ChipFamily;
71
- chipName: string | null = null;
72
- chipRevision: number | null = null;
73
- chipVariant: string | null = null;
81
+ __chipFamily?: ChipFamily;
82
+ __chipName: string | null = null;
83
+ __chipRevision: number | null = null;
84
+ __chipVariant: string | null = null;
74
85
  _efuses = new Array(4).fill(0);
75
86
  _flashsize = 4 * 1024 * 1024;
76
87
  debug = false;
@@ -79,6 +90,7 @@ export class ESPLoader extends EventTarget {
79
90
  flashSize: string | null = null;
80
91
 
81
92
  __inputBuffer?: number[];
93
+ __inputBufferReadIndex?: number;
82
94
  __totalBytesRead?: number;
83
95
  private _currentBaudRate: number = ESP_ROM_BAUD;
84
96
  private _maxUSBSerialBaudrate?: number;
@@ -86,7 +98,15 @@ export class ESPLoader extends EventTarget {
86
98
  private _isESP32S2NativeUSB: boolean = false;
87
99
  private _initializationSucceeded: boolean = false;
88
100
  private __commandLock: Promise<[number, number[]]> = Promise.resolve([0, []]);
89
- private _isReconfiguring: boolean = false;
101
+ private __isReconfiguring: boolean = false;
102
+ private __abandonCurrentOperation: boolean = false;
103
+
104
+ // Adaptive speed adjustment for flash read operations
105
+ private __adaptiveBlockMultiplier: number = 1;
106
+ private __adaptiveMaxInFlightMultiplier: number = 1;
107
+ private __consecutiveSuccessfulChunks: number = 0;
108
+ private __lastAdaptiveAdjustment: number = 0;
109
+ private __isCDCDevice: boolean = false;
90
110
 
91
111
  constructor(
92
112
  public port: SerialPort,
@@ -96,8 +116,109 @@ export class ESPLoader extends EventTarget {
96
116
  super();
97
117
  }
98
118
 
119
+ // Chip properties with parent delegation
120
+ // chipFamily accessed before initialization as designed
121
+ get chipFamily(): ChipFamily {
122
+ return this._parent ? this._parent.chipFamily : this.__chipFamily!;
123
+ }
124
+
125
+ set chipFamily(value: ChipFamily) {
126
+ if (this._parent) {
127
+ this._parent.chipFamily = value;
128
+ } else {
129
+ this.__chipFamily = value;
130
+ }
131
+ }
132
+
133
+ get chipName(): string | null {
134
+ return this._parent ? this._parent.chipName : this.__chipName;
135
+ }
136
+
137
+ set chipName(value: string | null) {
138
+ if (this._parent) {
139
+ this._parent.chipName = value;
140
+ } else {
141
+ this.__chipName = value;
142
+ }
143
+ }
144
+
145
+ get chipRevision(): number | null {
146
+ return this._parent ? this._parent.chipRevision : this.__chipRevision;
147
+ }
148
+
149
+ set chipRevision(value: number | null) {
150
+ if (this._parent) {
151
+ this._parent.chipRevision = value;
152
+ } else {
153
+ this.__chipRevision = value;
154
+ }
155
+ }
156
+
157
+ get chipVariant(): string | null {
158
+ return this._parent ? this._parent.chipVariant : this.__chipVariant;
159
+ }
160
+
161
+ set chipVariant(value: string | null) {
162
+ if (this._parent) {
163
+ this._parent.chipVariant = value;
164
+ } else {
165
+ this.__chipVariant = value;
166
+ }
167
+ }
168
+
99
169
  private get _inputBuffer(): number[] {
100
- return this._parent ? this._parent._inputBuffer : this.__inputBuffer!;
170
+ if (this._parent) {
171
+ return this._parent._inputBuffer;
172
+ }
173
+ if (this.__inputBuffer === undefined) {
174
+ throw new Error("_inputBuffer accessed before initialization");
175
+ }
176
+ return this.__inputBuffer;
177
+ }
178
+
179
+ private get _inputBufferReadIndex(): number {
180
+ return this._parent
181
+ ? this._parent._inputBufferReadIndex
182
+ : this.__inputBufferReadIndex || 0;
183
+ }
184
+
185
+ private set _inputBufferReadIndex(value: number) {
186
+ if (this._parent) {
187
+ this._parent._inputBufferReadIndex = value;
188
+ } else {
189
+ this.__inputBufferReadIndex = value;
190
+ }
191
+ }
192
+
193
+ // Get available bytes in buffer (from read index to end)
194
+ private get _inputBufferAvailable(): number {
195
+ return this._inputBuffer.length - this._inputBufferReadIndex;
196
+ }
197
+
198
+ // Read one byte from buffer (ring-buffer style with index pointer)
199
+ private _readByte(): number | undefined {
200
+ if (this._inputBufferReadIndex >= this._inputBuffer.length) {
201
+ return undefined;
202
+ }
203
+ return this._inputBuffer[this._inputBufferReadIndex++];
204
+ }
205
+
206
+ // Clear input buffer and reset read index
207
+ private _clearInputBuffer(): void {
208
+ this._inputBuffer.length = 0;
209
+ this._inputBufferReadIndex = 0;
210
+ }
211
+
212
+ // Compact buffer when read index gets too large (prevent memory growth)
213
+ private _compactInputBuffer(): void {
214
+ if (
215
+ this._inputBufferReadIndex > 1000 &&
216
+ this._inputBufferReadIndex > this._inputBuffer.length / 2
217
+ ) {
218
+ // Remove already-read bytes and reset index
219
+ this._inputBuffer.splice(0, this._inputBufferReadIndex);
220
+ this._inputBufferReadIndex = 0;
221
+ }
101
222
  }
102
223
 
103
224
  private get _totalBytesRead(): number {
@@ -126,6 +247,102 @@ export class ESPLoader extends EventTarget {
126
247
  }
127
248
  }
128
249
 
250
+ private get _isReconfiguring(): boolean {
251
+ return this._parent
252
+ ? this._parent._isReconfiguring
253
+ : this.__isReconfiguring;
254
+ }
255
+
256
+ private set _isReconfiguring(value: boolean) {
257
+ if (this._parent) {
258
+ this._parent._isReconfiguring = value;
259
+ } else {
260
+ this.__isReconfiguring = value;
261
+ }
262
+ }
263
+
264
+ private get _abandonCurrentOperation(): boolean {
265
+ return this._parent
266
+ ? this._parent._abandonCurrentOperation
267
+ : this.__abandonCurrentOperation;
268
+ }
269
+
270
+ private set _abandonCurrentOperation(value: boolean) {
271
+ if (this._parent) {
272
+ this._parent._abandonCurrentOperation = value;
273
+ } else {
274
+ this.__abandonCurrentOperation = value;
275
+ }
276
+ }
277
+
278
+ private get _adaptiveBlockMultiplier(): number {
279
+ return this._parent
280
+ ? this._parent._adaptiveBlockMultiplier
281
+ : this.__adaptiveBlockMultiplier;
282
+ }
283
+
284
+ private set _adaptiveBlockMultiplier(value: number) {
285
+ if (this._parent) {
286
+ this._parent._adaptiveBlockMultiplier = value;
287
+ } else {
288
+ this.__adaptiveBlockMultiplier = value;
289
+ }
290
+ }
291
+
292
+ private get _adaptiveMaxInFlightMultiplier(): number {
293
+ return this._parent
294
+ ? this._parent._adaptiveMaxInFlightMultiplier
295
+ : this.__adaptiveMaxInFlightMultiplier;
296
+ }
297
+
298
+ private set _adaptiveMaxInFlightMultiplier(value: number) {
299
+ if (this._parent) {
300
+ this._parent._adaptiveMaxInFlightMultiplier = value;
301
+ } else {
302
+ this.__adaptiveMaxInFlightMultiplier = value;
303
+ }
304
+ }
305
+
306
+ private get _consecutiveSuccessfulChunks(): number {
307
+ return this._parent
308
+ ? this._parent._consecutiveSuccessfulChunks
309
+ : this.__consecutiveSuccessfulChunks;
310
+ }
311
+
312
+ private set _consecutiveSuccessfulChunks(value: number) {
313
+ if (this._parent) {
314
+ this._parent._consecutiveSuccessfulChunks = value;
315
+ } else {
316
+ this.__consecutiveSuccessfulChunks = value;
317
+ }
318
+ }
319
+
320
+ private get _lastAdaptiveAdjustment(): number {
321
+ return this._parent
322
+ ? this._parent._lastAdaptiveAdjustment
323
+ : this.__lastAdaptiveAdjustment;
324
+ }
325
+
326
+ private set _lastAdaptiveAdjustment(value: number) {
327
+ if (this._parent) {
328
+ this._parent._lastAdaptiveAdjustment = value;
329
+ } else {
330
+ this.__lastAdaptiveAdjustment = value;
331
+ }
332
+ }
333
+
334
+ private get _isCDCDevice(): boolean {
335
+ return this._parent ? this._parent._isCDCDevice : this.__isCDCDevice;
336
+ }
337
+
338
+ private set _isCDCDevice(value: boolean) {
339
+ if (this._parent) {
340
+ this._parent._isCDCDevice = value;
341
+ } else {
342
+ this.__isCDCDevice = value;
343
+ }
344
+ }
345
+
129
346
  private detectUSBSerialChip(
130
347
  vendorId: number,
131
348
  productId: number,
@@ -182,6 +399,7 @@ export class ESPLoader extends EventTarget {
182
399
  async initialize() {
183
400
  if (!this._parent) {
184
401
  this.__inputBuffer = [];
402
+ this.__inputBufferReadIndex = 0;
185
403
  this.__totalBytesRead = 0;
186
404
 
187
405
  // Detect and log USB-Serial chip info
@@ -202,6 +420,15 @@ export class ESPLoader extends EventTarget {
202
420
  if (portInfo.usbVendorId === 0x303a && portInfo.usbProductId === 0x2) {
203
421
  this._isESP32S2NativeUSB = true;
204
422
  }
423
+
424
+ // Detect CDC devices for adaptive speed adjustment
425
+ // Espressif Native USB (VID: 0x303a) or CH343 (VID: 0x1a86, PID: 0x55d3)
426
+ if (
427
+ portInfo.usbVendorId === 0x303a ||
428
+ (portInfo.usbVendorId === 0x1a86 && portInfo.usbProductId === 0x55d3)
429
+ ) {
430
+ this._isCDCDevice = true;
431
+ }
205
432
  }
206
433
 
207
434
  // Don't await this promise so it doesn't block rest of method.
@@ -277,7 +504,7 @@ export class ESPLoader extends EventTarget {
277
504
  await this.drainInputBuffer(200);
278
505
 
279
506
  // Clear input buffer and re-sync to recover from failed command
280
- this._inputBuffer.length = 0;
507
+ this._clearInputBuffer();
281
508
  await sleep(SYNC_TIMEOUT);
282
509
 
283
510
  // Re-sync with the chip to ensure clean communication
@@ -393,6 +620,21 @@ export class ESPLoader extends EventTarget {
393
620
  };
394
621
  }
395
622
 
623
+ /**
624
+ * Get MAC address from efuses
625
+ */
626
+ async getMacAddress(): Promise<string> {
627
+ if (!this._initializationSucceeded) {
628
+ throw new Error(
629
+ "getMacAddress() requires initialize() to have completed successfully",
630
+ );
631
+ }
632
+ const macBytes = this.macAddr(); // chip-family-aware
633
+ return macBytes
634
+ .map((b) => b.toString(16).padStart(2, "0").toUpperCase())
635
+ .join(":");
636
+ }
637
+
396
638
  /**
397
639
  * @name readLoop
398
640
  * Reads data from the input stream and places it in the inputBuffer
@@ -427,7 +669,13 @@ export class ESPLoader extends EventTarget {
427
669
  }
428
670
  } catch {
429
671
  this.logger.error("Read loop got disconnected");
672
+ } finally {
673
+ // Always reset reconfiguring flag when read loop ends
674
+ // This prevents "Cannot write during port reconfiguration" errors
675
+ // when the read loop dies unexpectedly
676
+ this._isReconfiguring = false;
430
677
  }
678
+
431
679
  // Disconnected!
432
680
  this.connected = false;
433
681
 
@@ -453,6 +701,12 @@ export class ESPLoader extends EventTarget {
453
701
  }
454
702
 
455
703
  state_DTR = false;
704
+ state_RTS = false;
705
+
706
+ // ============================================================================
707
+ // Web Serial (Desktop) - DTR/RTS Signal Handling & Reset Strategies
708
+ // ============================================================================
709
+
456
710
  async setRTS(state: boolean) {
457
711
  await this.port.setSignals({ requestToSend: state });
458
712
  // Work-around for adapters on Windows using the usbser.sys driver:
@@ -467,6 +721,579 @@ export class ESPLoader extends EventTarget {
467
721
  await this.port.setSignals({ dataTerminalReady: state });
468
722
  }
469
723
 
724
+ /**
725
+ * @name hardResetUSBJTAGSerial
726
+ * USB-JTAG/Serial reset for Web Serial (Desktop)
727
+ */
728
+ async hardResetUSBJTAGSerial() {
729
+ await this.setRTS(false);
730
+ await this.setDTR(false); // Idle
731
+ await this.sleep(100);
732
+
733
+ await this.setDTR(true); // Set IO0
734
+ await this.setRTS(false);
735
+ await this.sleep(100);
736
+
737
+ await this.setRTS(true); // Reset
738
+ await this.setDTR(false);
739
+ await this.setRTS(true);
740
+ await this.sleep(100);
741
+
742
+ await this.setDTR(false);
743
+ await this.setRTS(false); // Chip out of reset
744
+
745
+ await this.sleep(200);
746
+ }
747
+
748
+ /**
749
+ * @name hardResetClassic
750
+ * Classic reset for Web Serial (Desktop)
751
+ */
752
+ async hardResetClassic() {
753
+ await this.setDTR(false); // IO0=HIGH
754
+ await this.setRTS(true); // EN=LOW, chip in reset
755
+ await this.sleep(100);
756
+ await this.setDTR(true); // IO0=LOW
757
+ await this.setRTS(false); // EN=HIGH, chip out of reset
758
+ await this.sleep(50);
759
+ await this.setDTR(false); // IO0=HIGH, done
760
+
761
+ await this.sleep(200);
762
+ }
763
+
764
+ // ============================================================================
765
+ // WebUSB (Android) - DTR/RTS Signal Handling & Reset Strategies
766
+ // ============================================================================
767
+
768
+ async setRTSWebUSB(state: boolean) {
769
+ this.state_RTS = state;
770
+ // Always specify both signals to avoid flipping the other line
771
+ // The WebUSB setSignals() now preserves unspecified signals, but being explicit is safer
772
+ await (this.port as WebUSBSerialPort).setSignals({
773
+ requestToSend: state,
774
+ dataTerminalReady: this.state_DTR,
775
+ });
776
+ }
777
+
778
+ async setDTRWebUSB(state: boolean) {
779
+ this.state_DTR = state;
780
+ // Always specify both signals to avoid flipping the other line
781
+ await (this.port as WebUSBSerialPort).setSignals({
782
+ dataTerminalReady: state,
783
+ requestToSend: this.state_RTS, // Explicitly preserve current RTS state
784
+ });
785
+ }
786
+
787
+ async setDTRandRTSWebUSB(dtr: boolean, rts: boolean) {
788
+ this.state_DTR = dtr;
789
+ this.state_RTS = rts;
790
+ await (this.port as WebUSBSerialPort).setSignals({
791
+ dataTerminalReady: dtr,
792
+ requestToSend: rts,
793
+ });
794
+ }
795
+
796
+ /**
797
+ * @name hardResetUSBJTAGSerialWebUSB
798
+ * USB-JTAG/Serial reset for WebUSB (Android)
799
+ */
800
+ async hardResetUSBJTAGSerialWebUSB() {
801
+ await this.setRTSWebUSB(false);
802
+ await this.setDTRWebUSB(false); // Idle
803
+ await this.sleep(100);
804
+
805
+ await this.setDTRWebUSB(true); // Set IO0
806
+ await this.setRTSWebUSB(false);
807
+ await this.sleep(100);
808
+
809
+ await this.setRTSWebUSB(true); // Reset
810
+ await this.setDTRWebUSB(false);
811
+ await this.setRTSWebUSB(true);
812
+ await this.sleep(100);
813
+
814
+ await this.setDTRWebUSB(false);
815
+ await this.setRTSWebUSB(false); // Chip out of reset
816
+
817
+ await this.sleep(200);
818
+ }
819
+
820
+ /**
821
+ * @name hardResetUSBJTAGSerialInvertedDTRWebUSB
822
+ * USB-JTAG/Serial reset with inverted DTR for WebUSB (Android)
823
+ */
824
+ async hardResetUSBJTAGSerialInvertedDTRWebUSB() {
825
+ await this.setRTSWebUSB(false);
826
+ await this.setDTRWebUSB(true); // Idle (DTR inverted)
827
+ await this.sleep(100);
828
+
829
+ await this.setDTRWebUSB(false); // Set IO0 (DTR inverted)
830
+ await this.setRTSWebUSB(false);
831
+ await this.sleep(100);
832
+
833
+ await this.setRTSWebUSB(true); // Reset
834
+ await this.setDTRWebUSB(true); // (DTR inverted)
835
+ await this.setRTSWebUSB(true);
836
+ await this.sleep(100);
837
+
838
+ await this.setDTRWebUSB(true); // (DTR inverted)
839
+ await this.setRTSWebUSB(false); // Chip out of reset
840
+
841
+ await this.sleep(200);
842
+ }
843
+
844
+ /**
845
+ * @name hardResetClassicWebUSB
846
+ * Classic reset for WebUSB (Android)
847
+ */
848
+ async hardResetClassicWebUSB() {
849
+ await this.setDTRWebUSB(false); // IO0=HIGH
850
+ await this.setRTSWebUSB(true); // EN=LOW, chip in reset
851
+ await this.sleep(100);
852
+ await this.setDTRWebUSB(true); // IO0=LOW
853
+ await this.setRTSWebUSB(false); // EN=HIGH, chip out of reset
854
+ await this.sleep(50);
855
+ await this.setDTRWebUSB(false); // IO0=HIGH, done
856
+ await this.sleep(200);
857
+ }
858
+
859
+ /**
860
+ * @name hardResetUnixTightWebUSB
861
+ * Unix Tight reset for WebUSB (Android) - sets DTR and RTS simultaneously
862
+ */
863
+ async hardResetUnixTightWebUSB() {
864
+ await this.setDTRandRTSWebUSB(false, false);
865
+ await this.setDTRandRTSWebUSB(true, true);
866
+ await this.setDTRandRTSWebUSB(false, true); // IO0=HIGH & EN=LOW, chip in reset
867
+ await this.sleep(100);
868
+ await this.setDTRandRTSWebUSB(true, false); // IO0=LOW & EN=HIGH, chip out of reset
869
+ await this.sleep(50);
870
+ await this.setDTRandRTSWebUSB(false, false); // IO0=HIGH, done
871
+ await this.setDTRWebUSB(false); // Ensure IO0=HIGH
872
+ await this.sleep(200);
873
+ }
874
+
875
+ /**
876
+ * @name hardResetClassicLongDelayWebUSB
877
+ * Classic reset with longer delays for WebUSB (Android)
878
+ * Specifically for CP2102/CH340 which may need more time
879
+ */
880
+ async hardResetClassicLongDelayWebUSB() {
881
+ await this.setDTRWebUSB(false); // IO0=HIGH
882
+ await this.setRTSWebUSB(true); // EN=LOW, chip in reset
883
+ await this.sleep(500); // Extra long delay
884
+ await this.setDTRWebUSB(true); // IO0=LOW
885
+ await this.setRTSWebUSB(false); // EN=HIGH, chip out of reset
886
+ await this.sleep(200);
887
+ await this.setDTRWebUSB(false); // IO0=HIGH, done
888
+ await this.sleep(500); // Extra long delay
889
+ }
890
+
891
+ /**
892
+ * @name hardResetClassicShortDelayWebUSB
893
+ * Classic reset with shorter delays for WebUSB (Android)
894
+ */
895
+ async hardResetClassicShortDelayWebUSB() {
896
+ await this.setDTRWebUSB(false); // IO0=HIGH
897
+ await this.setRTSWebUSB(true); // EN=LOW, chip in reset
898
+ await this.sleep(50);
899
+ await this.setDTRWebUSB(true); // IO0=LOW
900
+ await this.setRTSWebUSB(false); // EN=HIGH, chip out of reset
901
+ await this.sleep(25);
902
+ await this.setDTRWebUSB(false); // IO0=HIGH, done
903
+ await this.sleep(100);
904
+ }
905
+
906
+ /**
907
+ * @name hardResetInvertedWebUSB
908
+ * Inverted reset sequence for WebUSB (Android) - both signals inverted
909
+ */
910
+ async hardResetInvertedWebUSB() {
911
+ await this.setDTRWebUSB(true); // IO0=HIGH (inverted)
912
+ await this.setRTSWebUSB(false); // EN=LOW, chip in reset (inverted)
913
+ await this.sleep(100);
914
+ await this.setDTRWebUSB(false); // IO0=LOW (inverted)
915
+ await this.setRTSWebUSB(true); // EN=HIGH, chip out of reset (inverted)
916
+ await this.sleep(50);
917
+ await this.setDTRWebUSB(true); // IO0=HIGH, done (inverted)
918
+ await this.sleep(200);
919
+ }
920
+
921
+ /**
922
+ * @name hardResetInvertedDTRWebUSB
923
+ * Only DTR inverted for WebUSB (Android)
924
+ */
925
+ async hardResetInvertedDTRWebUSB() {
926
+ await this.setDTRWebUSB(true); // IO0=HIGH (DTR inverted)
927
+ await this.setRTSWebUSB(true); // EN=LOW, chip in reset (RTS normal)
928
+ await this.sleep(100);
929
+ await this.setDTRWebUSB(false); // IO0=LOW (DTR inverted)
930
+ await this.setRTSWebUSB(false); // EN=HIGH, chip out of reset (RTS normal)
931
+ await this.sleep(50);
932
+ await this.setDTRWebUSB(true); // IO0=HIGH, done (DTR inverted)
933
+ await this.sleep(200);
934
+ }
935
+
936
+ /**
937
+ * @name hardResetInvertedRTSWebUSB
938
+ * Only RTS inverted for WebUSB (Android)
939
+ */
940
+ async hardResetInvertedRTSWebUSB() {
941
+ await this.setDTRWebUSB(false); // IO0=HIGH (DTR normal)
942
+ await this.setRTSWebUSB(false); // EN=LOW, chip in reset (RTS inverted)
943
+ await this.sleep(100);
944
+ await this.setDTRWebUSB(true); // IO0=LOW (DTR normal)
945
+ await this.setRTSWebUSB(true); // EN=HIGH, chip out of reset (RTS inverted)
946
+ await this.sleep(50);
947
+ await this.setDTRWebUSB(false); // IO0=HIGH, done (DTR normal)
948
+ await this.sleep(200);
949
+ }
950
+
951
+ /**
952
+ * Check if we're using WebUSB (Android) or Web Serial (Desktop)
953
+ */
954
+ private isWebUSB(): boolean {
955
+ // WebUSBSerial class has isWebUSB flag - this is the most reliable check
956
+ return (this.port as WebUSBSerialPort).isWebUSB === true;
957
+ }
958
+
959
+ /**
960
+ * @name connectWithResetStrategies
961
+ * Try different reset strategies to enter bootloader mode
962
+ * Similar to esptool.py's connect() method with multiple reset strategies
963
+ */
964
+ async connectWithResetStrategies() {
965
+ const portInfo = this.port.getInfo();
966
+ const isUSBJTAGSerial = portInfo.usbProductId === USB_JTAG_SERIAL_PID;
967
+ const isEspressifUSB = portInfo.usbVendorId === 0x303a;
968
+
969
+ // this.logger.log(
970
+ // `Detected USB: VID=0x${portInfo.usbVendorId?.toString(16) || "unknown"}, PID=0x${portInfo.usbProductId?.toString(16) || "unknown"}`,
971
+ // );
972
+
973
+ // Define reset strategies to try in order
974
+ const resetStrategies: Array<{ name: string; fn: () => Promise<void> }> =
975
+ [];
976
+
977
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
978
+ const self = this;
979
+
980
+ // WebUSB (Android) uses different reset methods than Web Serial (Desktop)
981
+ if (this.isWebUSB()) {
982
+ // For USB-Serial chips (CP2102, CH340, etc.), try inverted strategies first
983
+ const isUSBSerialChip = !isUSBJTAGSerial && !isEspressifUSB;
984
+
985
+ // Detect specific chip types once
986
+ const isCP2102 = portInfo.usbVendorId === 0x10c4;
987
+ const isCH34x = portInfo.usbVendorId === 0x1a86;
988
+
989
+ // Check for ESP32-S2 Native USB (VID: 0x303a, PID: 0x0002)
990
+ const isESP32S2NativeUSB =
991
+ portInfo.usbVendorId === 0x303a && portInfo.usbProductId === 0x0002;
992
+
993
+ // WebUSB Strategy 1: USB-JTAG/Serial reset (for Native USB only)
994
+ if (isUSBJTAGSerial || isEspressifUSB) {
995
+ if (isESP32S2NativeUSB) {
996
+ // ESP32-S2 Native USB: Try multiple strategies
997
+ // The device might be in JTAG mode OR CDC mode
998
+
999
+ // Strategy 1: USB-JTAG/Serial (works in CDC mode on Desktop)
1000
+ resetStrategies.push({
1001
+ name: "USB-JTAG/Serial (WebUSB) - ESP32-S2",
1002
+ fn: async () => {
1003
+ return await self.hardResetUSBJTAGSerialWebUSB();
1004
+ },
1005
+ });
1006
+
1007
+ // Strategy 2: USB-JTAG/Serial Inverted DTR (works in JTAG mode)
1008
+ resetStrategies.push({
1009
+ name: "USB-JTAG/Serial Inverted DTR (WebUSB) - ESP32-S2",
1010
+ fn: async () => {
1011
+ return await self.hardResetUSBJTAGSerialInvertedDTRWebUSB();
1012
+ },
1013
+ });
1014
+
1015
+ // Strategy 3: UnixTight (CDC fallback)
1016
+ resetStrategies.push({
1017
+ name: "UnixTight (WebUSB) - ESP32-S2 CDC",
1018
+ fn: async () => {
1019
+ return await self.hardResetUnixTightWebUSB();
1020
+ },
1021
+ });
1022
+
1023
+ // Strategy 4: Classic reset (CDC fallback)
1024
+ resetStrategies.push({
1025
+ name: "Classic (WebUSB) - ESP32-S2 CDC",
1026
+ fn: async () => {
1027
+ return await self.hardResetClassicWebUSB();
1028
+ },
1029
+ });
1030
+ } else {
1031
+ // Other USB-JTAG chips: Try Inverted DTR first - works best for ESP32-H2 and other JTAG chips
1032
+ resetStrategies.push({
1033
+ name: "USB-JTAG/Serial Inverted DTR (WebUSB)",
1034
+ fn: async () => {
1035
+ return await self.hardResetUSBJTAGSerialInvertedDTRWebUSB();
1036
+ },
1037
+ });
1038
+ resetStrategies.push({
1039
+ name: "USB-JTAG/Serial (WebUSB)",
1040
+ fn: async () => {
1041
+ return await self.hardResetUSBJTAGSerialWebUSB();
1042
+ },
1043
+ });
1044
+ resetStrategies.push({
1045
+ name: "Inverted DTR Classic (WebUSB)",
1046
+ fn: async () => {
1047
+ return await self.hardResetInvertedDTRWebUSB();
1048
+ },
1049
+ });
1050
+ }
1051
+ }
1052
+
1053
+ // For USB-Serial chips, try inverted strategies first
1054
+ if (isUSBSerialChip) {
1055
+ if (isCH34x) {
1056
+ // CH340/CH343: UnixTight works best (like CP2102)
1057
+ resetStrategies.push({
1058
+ name: "UnixTight (WebUSB) - CH34x",
1059
+ fn: async () => {
1060
+ return await self.hardResetUnixTightWebUSB();
1061
+ },
1062
+ });
1063
+ resetStrategies.push({
1064
+ name: "Classic (WebUSB) - CH34x",
1065
+ fn: async () => {
1066
+ return await self.hardResetClassicWebUSB();
1067
+ },
1068
+ });
1069
+ resetStrategies.push({
1070
+ name: "Inverted Both (WebUSB) - CH34x",
1071
+ fn: async () => {
1072
+ return await self.hardResetInvertedWebUSB();
1073
+ },
1074
+ });
1075
+ resetStrategies.push({
1076
+ name: "Inverted RTS (WebUSB) - CH34x",
1077
+ fn: async () => {
1078
+ return await self.hardResetInvertedRTSWebUSB();
1079
+ },
1080
+ });
1081
+ resetStrategies.push({
1082
+ name: "Inverted DTR (WebUSB) - CH34x",
1083
+ fn: async () => {
1084
+ return await self.hardResetInvertedDTRWebUSB();
1085
+ },
1086
+ });
1087
+ } else if (isCP2102) {
1088
+ // CP2102: UnixTight works best (tested and confirmed)
1089
+ // Try it first, then fallback to other strategies
1090
+
1091
+ resetStrategies.push({
1092
+ name: "UnixTight (WebUSB) - CP2102",
1093
+ fn: async () => {
1094
+ return await self.hardResetUnixTightWebUSB();
1095
+ },
1096
+ });
1097
+
1098
+ resetStrategies.push({
1099
+ name: "Classic (WebUSB) - CP2102",
1100
+ fn: async () => {
1101
+ return await self.hardResetClassicWebUSB();
1102
+ },
1103
+ });
1104
+
1105
+ resetStrategies.push({
1106
+ name: "Inverted Both (WebUSB) - CP2102",
1107
+ fn: async () => {
1108
+ return await self.hardResetInvertedWebUSB();
1109
+ },
1110
+ });
1111
+
1112
+ resetStrategies.push({
1113
+ name: "Inverted RTS (WebUSB) - CP2102",
1114
+ fn: async () => {
1115
+ return await self.hardResetInvertedRTSWebUSB();
1116
+ },
1117
+ });
1118
+
1119
+ resetStrategies.push({
1120
+ name: "Inverted DTR (WebUSB) - CP2102",
1121
+ fn: async () => {
1122
+ return await self.hardResetInvertedDTRWebUSB();
1123
+ },
1124
+ });
1125
+ } else {
1126
+ // For other USB-Serial chips, try UnixTight first, then multiple strategies
1127
+ resetStrategies.push({
1128
+ name: "UnixTight (WebUSB)",
1129
+ fn: async () => {
1130
+ return await self.hardResetUnixTightWebUSB();
1131
+ },
1132
+ });
1133
+ resetStrategies.push({
1134
+ name: "Classic (WebUSB)",
1135
+ fn: async function () {
1136
+ return await self.hardResetClassicWebUSB();
1137
+ },
1138
+ });
1139
+ resetStrategies.push({
1140
+ name: "Inverted Both (WebUSB)",
1141
+ fn: async function () {
1142
+ return await self.hardResetInvertedWebUSB();
1143
+ },
1144
+ });
1145
+ resetStrategies.push({
1146
+ name: "Inverted RTS (WebUSB)",
1147
+ fn: async function () {
1148
+ return await self.hardResetInvertedRTSWebUSB();
1149
+ },
1150
+ });
1151
+ resetStrategies.push({
1152
+ name: "Inverted DTR (WebUSB)",
1153
+ fn: async function () {
1154
+ return await self.hardResetInvertedDTRWebUSB();
1155
+ },
1156
+ });
1157
+ }
1158
+ }
1159
+
1160
+ // Add general fallback strategies only for non-CP2102 and non-ESP32-S2 Native USB chips
1161
+ if (!isCP2102 && !isESP32S2NativeUSB) {
1162
+ // Classic reset (for chips not handled above)
1163
+ if (portInfo.usbVendorId !== 0x1a86) {
1164
+ resetStrategies.push({
1165
+ name: "Classic (WebUSB)",
1166
+ fn: async function () {
1167
+ return await self.hardResetClassicWebUSB();
1168
+ },
1169
+ });
1170
+ }
1171
+
1172
+ // UnixTight reset (sets DTR/RTS simultaneously)
1173
+ resetStrategies.push({
1174
+ name: "UnixTight (WebUSB)",
1175
+ fn: async function () {
1176
+ return await self.hardResetUnixTightWebUSB();
1177
+ },
1178
+ });
1179
+
1180
+ // WebUSB Strategy: Classic with long delays
1181
+ resetStrategies.push({
1182
+ name: "Classic Long Delay (WebUSB)",
1183
+ fn: async function () {
1184
+ return await self.hardResetClassicLongDelayWebUSB();
1185
+ },
1186
+ });
1187
+
1188
+ // WebUSB Strategy: Classic with short delays
1189
+ resetStrategies.push({
1190
+ name: "Classic Short Delay (WebUSB)",
1191
+ fn: async function () {
1192
+ return await self.hardResetClassicShortDelayWebUSB();
1193
+ },
1194
+ });
1195
+
1196
+ // WebUSB Strategy: USB-JTAG/Serial fallback
1197
+ if (!isUSBJTAGSerial && !isEspressifUSB) {
1198
+ resetStrategies.push({
1199
+ name: "USB-JTAG/Serial fallback (WebUSB)",
1200
+ fn: async function () {
1201
+ return await self.hardResetUSBJTAGSerialWebUSB();
1202
+ },
1203
+ });
1204
+ }
1205
+ }
1206
+ } else {
1207
+ // Strategy: USB-JTAG/Serial reset
1208
+ if (isUSBJTAGSerial || isEspressifUSB) {
1209
+ resetStrategies.push({
1210
+ name: "USB-JTAG/Serial",
1211
+ fn: async function () {
1212
+ return await self.hardResetUSBJTAGSerial();
1213
+ },
1214
+ });
1215
+ }
1216
+
1217
+ // Strategy: Classic reset
1218
+ resetStrategies.push({
1219
+ name: "Classic",
1220
+ fn: async function () {
1221
+ return await self.hardResetClassic();
1222
+ },
1223
+ });
1224
+
1225
+ // Strategy: USB-JTAG/Serial fallback
1226
+ if (!isUSBJTAGSerial && !isEspressifUSB) {
1227
+ resetStrategies.push({
1228
+ name: "USB-JTAG/Serial (fallback)",
1229
+ fn: async function () {
1230
+ return await self.hardResetUSBJTAGSerial();
1231
+ },
1232
+ });
1233
+ }
1234
+ }
1235
+
1236
+ let lastError: Error | null = null;
1237
+
1238
+ // Try each reset strategy with timeout
1239
+ for (const strategy of resetStrategies) {
1240
+ try {
1241
+ // Check if port is still open, if not, skip this strategy
1242
+ if (!this.connected || !this.port.writable) {
1243
+ this.logger.log(`Port disconnected, skipping ${strategy.name} reset`);
1244
+ continue;
1245
+ }
1246
+
1247
+ // Clear abandon flag before starting new strategy
1248
+ this._abandonCurrentOperation = false;
1249
+
1250
+ await strategy.fn();
1251
+
1252
+ // Try to sync after reset with internally time-bounded sync (3 seconds per strategy)
1253
+ const syncSuccess = await this.syncWithTimeout(3000);
1254
+
1255
+ if (syncSuccess) {
1256
+ // Sync succeeded
1257
+ this.logger.log(
1258
+ `Connected successfully with ${strategy.name} reset.`,
1259
+ );
1260
+ return;
1261
+ } else {
1262
+ throw new Error("Sync timeout or abandoned");
1263
+ }
1264
+ } catch (error) {
1265
+ lastError = error as Error;
1266
+ this.logger.log(
1267
+ `${strategy.name} reset failed: ${(error as Error).message}`,
1268
+ );
1269
+
1270
+ // Set abandon flag to stop any in-flight operations
1271
+ this._abandonCurrentOperation = true;
1272
+
1273
+ // Wait a bit for in-flight operations to abort
1274
+ await sleep(100);
1275
+
1276
+ // If port got disconnected, we can't try more strategies
1277
+ if (!this.connected || !this.port.writable) {
1278
+ this.logger.log(`Port disconnected during reset attempt`);
1279
+ break;
1280
+ }
1281
+
1282
+ // Clear buffers before trying next strategy
1283
+ this._clearInputBuffer();
1284
+ await this.drainInputBuffer(200);
1285
+ await this.flushSerialBuffers();
1286
+ }
1287
+ }
1288
+
1289
+ // All strategies failed - reset abandon flag before throwing
1290
+ this._abandonCurrentOperation = false;
1291
+
1292
+ throw new Error(
1293
+ `Couldn't sync to ESP. Try resetting manually. Last error: ${lastError?.message}`,
1294
+ );
1295
+ }
1296
+
470
1297
  async hardReset(bootloader = false) {
471
1298
  if (bootloader) {
472
1299
  // enter flash mode
@@ -474,15 +1301,31 @@ export class ESPLoader extends EventTarget {
474
1301
  await this.hardResetUSBJTAGSerial();
475
1302
  this.logger.log("USB-JTAG/Serial reset.");
476
1303
  } else {
477
- await this.hardResetClassic();
478
- this.logger.log("Classic reset.");
1304
+ // Use different reset strategy for WebUSB (Android) vs Web Serial (Desktop)
1305
+ if (this.isWebUSB()) {
1306
+ await this.hardResetClassicWebUSB();
1307
+ this.logger.log("Classic reset (WebUSB/Android).");
1308
+ } else {
1309
+ await this.hardResetClassic();
1310
+ this.logger.log("Classic reset.");
1311
+ }
479
1312
  }
480
1313
  } else {
481
- // just reset
482
- await this.setRTS(true); // EN->LOW
483
- await this.sleep(100);
484
- await this.setRTS(false);
485
- this.logger.log("Hard reset.");
1314
+ // just reset (no bootloader mode)
1315
+ if (this.isWebUSB()) {
1316
+ // WebUSB: Use longer delays for better compatibility
1317
+ await this.setRTSWebUSB(true); // EN->LOW
1318
+ await this.sleep(200);
1319
+ await this.setRTSWebUSB(false);
1320
+ await this.sleep(200);
1321
+ this.logger.log("Hard reset (WebUSB).");
1322
+ } else {
1323
+ // Web Serial: Standard reset
1324
+ await this.setRTS(true); // EN->LOW
1325
+ await this.sleep(100);
1326
+ await this.setRTS(false);
1327
+ this.logger.log("Hard reset.");
1328
+ }
486
1329
  }
487
1330
  await new Promise((resolve) => setTimeout(resolve, 1000));
488
1331
  }
@@ -613,6 +1456,13 @@ export class ESPLoader extends EventTarget {
613
1456
  statusLen = 4;
614
1457
  } else if ([2, 4].includes(data.length)) {
615
1458
  statusLen = data.length;
1459
+ } else {
1460
+ // Default to 2-byte status if we can't determine
1461
+ // This prevents silent data corruption when statusLen would be 0
1462
+ statusLen = 2;
1463
+ this.logger.debug(
1464
+ `Unknown chip family, defaulting to 2-byte status (opcode: ${toHex(opcode)}, data.length: ${data.length})`,
1465
+ );
616
1466
  }
617
1467
  }
618
1468
 
@@ -656,6 +1506,7 @@ export class ESPLoader extends EventTarget {
656
1506
  ...pack("<BBHI", 0x00, opcode, buffer.length, checksum),
657
1507
  ...buffer,
658
1508
  ]);
1509
+
659
1510
  if (this.debug) {
660
1511
  this.logger.debug(
661
1512
  `Writing ${packet.length} byte${packet.length == 1 ? "" : "s"}:`,
@@ -669,80 +1520,188 @@ export class ESPLoader extends EventTarget {
669
1520
  * @name readPacket
670
1521
  * Generator to read SLIP packets from a serial port.
671
1522
  * Yields one full SLIP packet at a time, raises exception on timeout or invalid data.
1523
+ *
1524
+ * Two implementations:
1525
+ * - Burst: CDC devices (Native USB) and CH343 - very fast processing
1526
+ * - Byte-by-byte: CH340, CP2102, and other USB-Serial adapters - stable fast processing
672
1527
  */
673
-
674
1528
  async readPacket(timeout: number): Promise<number[]> {
675
1529
  let partialPacket: number[] | null = null;
676
1530
  let inEscape = false;
677
1531
 
678
- const startTime = Date.now();
1532
+ // CDC devices use burst processing, non-CDC use byte-by-byte
1533
+ if (this._isCDCDevice) {
1534
+ // Burst version: Process all available bytes in one pass for ultra-high-speed transfers
1535
+ // Used for: CDC devices (all platforms) and CH343
1536
+ const startTime = Date.now();
1537
+
1538
+ while (true) {
1539
+ // Check abandon flag (for reset strategy timeout)
1540
+ if (this._abandonCurrentOperation) {
1541
+ throw new SlipReadError(
1542
+ "Operation abandoned (reset strategy timeout)",
1543
+ );
1544
+ }
679
1545
 
680
- while (true) {
681
- // Check timeout
682
- if (Date.now() - startTime > timeout) {
683
- const waitingFor = partialPacket === null ? "header" : "content";
684
- throw new SlipReadError("Timed out waiting for packet " + waitingFor);
685
- }
1546
+ // Check timeout
1547
+ if (Date.now() - startTime > timeout) {
1548
+ const waitingFor = partialPacket === null ? "header" : "content";
1549
+ throw new SlipReadError("Timed out waiting for packet " + waitingFor);
1550
+ }
686
1551
 
687
- // If no data available, wait a bit
688
- if (this._inputBuffer.length === 0) {
689
- await sleep(1);
690
- continue;
691
- }
1552
+ // If no data available, wait a bit
1553
+ if (this._inputBufferAvailable === 0) {
1554
+ await sleep(1);
1555
+ continue;
1556
+ }
692
1557
 
693
- // Process all available bytes without going back to outer loop
694
- // This is critical for handling high-speed burst transfers
695
- while (this._inputBuffer.length > 0) {
696
- const b = this._inputBuffer.shift()!;
1558
+ // Process all available bytes without going back to outer loop
1559
+ // This is critical for handling high-speed burst transfers
1560
+ while (this._inputBufferAvailable > 0) {
1561
+ // Periodic timeout check to prevent hang on slow data
1562
+ if (Date.now() - startTime > timeout) {
1563
+ const waitingFor = partialPacket === null ? "header" : "content";
1564
+ throw new SlipReadError(
1565
+ "Timed out waiting for packet " + waitingFor,
1566
+ );
1567
+ }
1568
+ const b = this._readByte()!;
697
1569
 
698
- if (partialPacket === null) {
699
- // waiting for packet header
700
- if (b == 0xc0) {
701
- partialPacket = [];
702
- } else {
703
- if (this.debug) {
704
- this.logger.debug("Read invalid data: " + toHex(b));
1570
+ if (partialPacket === null) {
1571
+ // waiting for packet header
1572
+ if (b == 0xc0) {
1573
+ partialPacket = [];
1574
+ } else {
1575
+ if (this.debug) {
1576
+ this.logger.debug("Read invalid data: " + toHex(b));
1577
+ this.logger.debug(
1578
+ "Remaining data in serial buffer: " +
1579
+ hexFormatter(this._inputBuffer),
1580
+ );
1581
+ }
1582
+ throw new SlipReadError(
1583
+ "Invalid head of packet (" + toHex(b) + ")",
1584
+ );
1585
+ }
1586
+ } else if (inEscape) {
1587
+ // part-way through escape sequence
1588
+ inEscape = false;
1589
+ if (b == 0xdc) {
1590
+ partialPacket.push(0xc0);
1591
+ } else if (b == 0xdd) {
1592
+ partialPacket.push(0xdb);
1593
+ } else {
1594
+ if (this.debug) {
1595
+ this.logger.debug("Read invalid data: " + toHex(b));
1596
+ this.logger.debug(
1597
+ "Remaining data in serial buffer: " +
1598
+ hexFormatter(this._inputBuffer),
1599
+ );
1600
+ }
1601
+ throw new SlipReadError(
1602
+ "Invalid SLIP escape (0xdb, " + toHex(b) + ")",
1603
+ );
1604
+ }
1605
+ } else if (b == 0xdb) {
1606
+ // start of escape sequence
1607
+ inEscape = true;
1608
+ } else if (b == 0xc0) {
1609
+ // end of packet
1610
+ if (this.debug)
705
1611
  this.logger.debug(
706
- "Remaining data in serial buffer: " +
707
- hexFormatter(this._inputBuffer),
1612
+ "Received full packet: " + hexFormatter(partialPacket),
1613
+ );
1614
+ // Compact buffer periodically to prevent memory growth
1615
+ this._compactInputBuffer();
1616
+ return partialPacket;
1617
+ } else {
1618
+ // normal byte in packet
1619
+ partialPacket.push(b);
1620
+ }
1621
+ }
1622
+ }
1623
+ } else {
1624
+ // Byte-by-byte version: Stable for non CDC USB-Serial adapters (CH340, CP2102, etc.)
1625
+ let readBytes: number[] = [];
1626
+ while (true) {
1627
+ // Check abandon flag (for reset strategy timeout)
1628
+ if (this._abandonCurrentOperation) {
1629
+ throw new SlipReadError(
1630
+ "Operation abandoned (reset strategy timeout)",
1631
+ );
1632
+ }
1633
+
1634
+ const stamp = Date.now();
1635
+ readBytes = [];
1636
+ while (Date.now() - stamp < timeout) {
1637
+ if (this._inputBufferAvailable > 0) {
1638
+ readBytes.push(this._readByte()!);
1639
+ break;
1640
+ } else {
1641
+ // Reduced sleep time for faster response during high-speed transfers
1642
+ await sleep(1);
1643
+ }
1644
+ }
1645
+ if (readBytes.length == 0) {
1646
+ const waitingFor = partialPacket === null ? "header" : "content";
1647
+ throw new SlipReadError("Timed out waiting for packet " + waitingFor);
1648
+ }
1649
+ if (this.debug)
1650
+ this.logger.debug(
1651
+ "Read " + readBytes.length + " bytes: " + hexFormatter(readBytes),
1652
+ );
1653
+ for (const b of readBytes) {
1654
+ if (partialPacket === null) {
1655
+ // waiting for packet header
1656
+ if (b == 0xc0) {
1657
+ partialPacket = [];
1658
+ } else {
1659
+ if (this.debug) {
1660
+ this.logger.debug("Read invalid data: " + toHex(b));
1661
+ this.logger.debug(
1662
+ "Remaining data in serial buffer: " +
1663
+ hexFormatter(this._inputBuffer),
1664
+ );
1665
+ }
1666
+ throw new SlipReadError(
1667
+ "Invalid head of packet (" + toHex(b) + ")",
708
1668
  );
709
1669
  }
710
- throw new SlipReadError(
711
- "Invalid head of packet (" + toHex(b) + ")",
712
- );
713
- }
714
- } else if (inEscape) {
715
- // part-way through escape sequence
716
- inEscape = false;
717
- if (b == 0xdc) {
718
- partialPacket.push(0xc0);
719
- } else if (b == 0xdd) {
720
- partialPacket.push(0xdb);
721
- } else {
722
- if (this.debug) {
723
- this.logger.debug("Read invalid data: " + toHex(b));
724
- this.logger.debug(
725
- "Remaining data in serial buffer: " +
726
- hexFormatter(this._inputBuffer),
1670
+ } else if (inEscape) {
1671
+ // part-way through escape sequence
1672
+ inEscape = false;
1673
+ if (b == 0xdc) {
1674
+ partialPacket.push(0xc0);
1675
+ } else if (b == 0xdd) {
1676
+ partialPacket.push(0xdb);
1677
+ } else {
1678
+ if (this.debug) {
1679
+ this.logger.debug("Read invalid data: " + toHex(b));
1680
+ this.logger.debug(
1681
+ "Remaining data in serial buffer: " +
1682
+ hexFormatter(this._inputBuffer),
1683
+ );
1684
+ }
1685
+ throw new SlipReadError(
1686
+ "Invalid SLIP escape (0xdb, " + toHex(b) + ")",
727
1687
  );
728
1688
  }
729
- throw new SlipReadError(
730
- "Invalid SLIP escape (0xdb, " + toHex(b) + ")",
731
- );
1689
+ } else if (b == 0xdb) {
1690
+ // start of escape sequence
1691
+ inEscape = true;
1692
+ } else if (b == 0xc0) {
1693
+ // end of packet
1694
+ if (this.debug)
1695
+ this.logger.debug(
1696
+ "Received full packet: " + hexFormatter(partialPacket),
1697
+ );
1698
+ // Compact buffer periodically to prevent memory growth
1699
+ this._compactInputBuffer();
1700
+ return partialPacket;
1701
+ } else {
1702
+ // normal byte in packet
1703
+ partialPacket.push(b);
732
1704
  }
733
- } else if (b == 0xdb) {
734
- // start of escape sequence
735
- inEscape = true;
736
- } else if (b == 0xc0) {
737
- // end of packet
738
- if (this.debug)
739
- this.logger.debug(
740
- "Received full packet: " + hexFormatter(partialPacket),
741
- );
742
- return partialPacket;
743
- } else {
744
- // normal byte in packet
745
- partialPacket.push(b);
746
1705
  }
747
1706
  }
748
1707
  }
@@ -766,6 +1725,7 @@ export class ESPLoader extends EventTarget {
766
1725
  }
767
1726
 
768
1727
  const [resp, opRet, , val] = unpack("<BBHI", packet.slice(0, 8));
1728
+
769
1729
  if (resp != 1) {
770
1730
  continue;
771
1731
  }
@@ -780,7 +1740,7 @@ export class ESPLoader extends EventTarget {
780
1740
  throw new Error(`Invalid (unsupported) command ${toHex(opcode)}`);
781
1741
  }
782
1742
  }
783
- throw "Response doesn't match request";
1743
+ throw new Error("Response doesn't match request");
784
1744
  }
785
1745
 
786
1746
  /**
@@ -843,9 +1803,10 @@ export class ESPLoader extends EventTarget {
843
1803
  }
844
1804
 
845
1805
  async reconfigurePort(baud: number) {
846
- try {
847
- this._isReconfiguring = true;
1806
+ // Block new writes during the entire reconfiguration (all paths)
1807
+ this._isReconfiguring = true;
848
1808
 
1809
+ try {
849
1810
  // Wait for pending writes to complete
850
1811
  try {
851
1812
  await this._writeChain;
@@ -853,6 +1814,35 @@ export class ESPLoader extends EventTarget {
853
1814
  this.logger.debug(`Pending write error during reconfigure: ${err}`);
854
1815
  }
855
1816
 
1817
+ // WebUSB: Check if we should use setBaudRate() or close/reopen
1818
+ if (this.isWebUSB()) {
1819
+ const portInfo = this.port.getInfo();
1820
+ const isCH343 =
1821
+ portInfo.usbVendorId === 0x1a86 && portInfo.usbProductId === 0x55d3;
1822
+
1823
+ // CH343 is a CDC device and MUST use close/reopen
1824
+ // Other chips (CH340, CP2102, FTDI) MUST use setBaudRate()
1825
+ if (
1826
+ !isCH343 &&
1827
+ typeof (this.port as WebUSBSerialPort).setBaudRate === "function"
1828
+ ) {
1829
+ // this.logger.log(
1830
+ // `[WebUSB] Changing baudrate to ${baud} using setBaudRate()...`,
1831
+ // );
1832
+ await (this.port as WebUSBSerialPort).setBaudRate(baud);
1833
+ // this.logger.log(`[WebUSB] Baudrate changed to ${baud}`);
1834
+
1835
+ // Give the chip time to adjust to new baudrate
1836
+ await sleep(100);
1837
+ return;
1838
+ } else if (isCH343) {
1839
+ // this.logger.log(
1840
+ // `[WebUSB] CH343 detected - using close/reopen for baudrate change`,
1841
+ // );
1842
+ }
1843
+ }
1844
+
1845
+ // Web Serial or CH343: Close and reopen port
856
1846
  // Release persistent writer before closing
857
1847
  if (this._writer) {
858
1848
  try {
@@ -882,137 +1872,50 @@ export class ESPLoader extends EventTarget {
882
1872
  this.logger.error(`Reconfigure port error: ${e}`);
883
1873
  throw new Error(`Unable to change the baud rate to ${baud}: ${e}`);
884
1874
  } finally {
1875
+ // Always reset flag, even on error or early return
885
1876
  this._isReconfiguring = false;
886
1877
  }
887
1878
  }
888
1879
 
889
1880
  /**
890
- * @name connectWithResetStrategies
891
- * Try different reset strategies to enter bootloader mode
892
- * Similar to esptool.py's connect() method with multiple reset strategies
1881
+ * @name syncWithTimeout
1882
+ * Sync with timeout that can be abandoned (for reset strategy loop)
1883
+ * This is internally time-bounded and checks the abandon flag
893
1884
  */
894
- async connectWithResetStrategies() {
895
- const portInfo = this.port.getInfo();
896
- const isUSBJTAGSerial = portInfo.usbProductId === USB_JTAG_SERIAL_PID;
897
- const isEspressifUSB = portInfo.usbVendorId === 0x303a;
898
-
899
- this.logger.log(
900
- `Detected USB: VID=0x${portInfo.usbVendorId?.toString(16) || "unknown"}, PID=0x${portInfo.usbProductId?.toString(16) || "unknown"}`,
901
- );
902
-
903
- // Define reset strategies to try in order
904
- const resetStrategies: Array<{ name: string; fn: () => Promise<void> }> =
905
- [];
906
-
907
- // Strategy 1: USB-JTAG/Serial reset (for ESP32-C3, C6, S3, etc.)
908
- // Try this first if we detect Espressif USB VID or the specific PID
909
- if (isUSBJTAGSerial || isEspressifUSB) {
910
- resetStrategies.push({
911
- name: "USB-JTAG/Serial",
912
- fn: async () => await this.hardResetUSBJTAGSerial(),
913
- });
914
- }
1885
+ async syncWithTimeout(timeoutMs: number): Promise<boolean> {
1886
+ const startTime = Date.now();
915
1887
 
916
- // Strategy 2: Classic reset (for USB-to-Serial bridges)
917
- resetStrategies.push({
918
- name: "Classic",
919
- fn: async () => await this.hardResetClassic(),
920
- });
1888
+ for (let i = 0; i < 5; i++) {
1889
+ // Check if we've exceeded the timeout
1890
+ if (Date.now() - startTime > timeoutMs) {
1891
+ return false;
1892
+ }
921
1893
 
922
- // Strategy 3: If USB-JTAG/Serial was not tried yet, try it as fallback
923
- if (!isUSBJTAGSerial && !isEspressifUSB) {
924
- resetStrategies.push({
925
- name: "USB-JTAG/Serial (fallback)",
926
- fn: async () => await this.hardResetUSBJTAGSerial(),
927
- });
928
- }
1894
+ // Check abandon flag
1895
+ if (this._abandonCurrentOperation) {
1896
+ return false;
1897
+ }
929
1898
 
930
- let lastError: Error | null = null;
1899
+ this._clearInputBuffer();
931
1900
 
932
- // Try each reset strategy
933
- for (const strategy of resetStrategies) {
934
1901
  try {
935
- this.logger.log(`Trying ${strategy.name} reset...`);
936
-
937
- // Check if port is still open, if not, skip this strategy
938
- if (!this.connected || !this.port.writable) {
939
- this.logger.log(`Port disconnected, skipping ${strategy.name} reset`);
940
- continue;
1902
+ const response = await this._sync();
1903
+ if (response) {
1904
+ await sleep(SYNC_TIMEOUT);
1905
+ return true;
941
1906
  }
942
-
943
- await strategy.fn();
944
-
945
- // Try to sync after reset
946
- await this.sync();
947
-
948
- // If we get here, sync succeeded
949
- this.logger.log(`Connected successfully with ${strategy.name} reset.`);
950
- return;
951
- } catch (error) {
952
- lastError = error as Error;
953
- this.logger.log(
954
- `${strategy.name} reset failed: ${(error as Error).message}`,
955
- );
956
-
957
- // If port got disconnected, we can't try more strategies
958
- if (!this.connected || !this.port.writable) {
959
- this.logger.log(`Port disconnected during reset attempt`);
960
- break;
1907
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1908
+ } catch (e) {
1909
+ // Check abandon flag after error
1910
+ if (this._abandonCurrentOperation) {
1911
+ return false;
961
1912
  }
962
-
963
- // Clear buffers before trying next strategy
964
- this._inputBuffer.length = 0;
965
- await this.drainInputBuffer(200);
966
- await this.flushSerialBuffers();
967
1913
  }
968
- }
969
-
970
- // All strategies failed
971
- throw new Error(
972
- `Couldn't sync to ESP. Try resetting manually. Last error: ${lastError?.message}`,
973
- );
974
- }
975
-
976
- /**
977
- * @name hardResetUSBJTAGSerial
978
- * USB-JTAG/Serial reset sequence for ESP32-C3, ESP32-S3, ESP32-C6, etc.
979
- */
980
- async hardResetUSBJTAGSerial() {
981
- await this.setRTS(false);
982
- await this.setDTR(false); // Idle
983
- await this.sleep(100);
984
-
985
- await this.setDTR(true); // Set IO0
986
- await this.setRTS(false);
987
- await this.sleep(100);
988
-
989
- await this.setRTS(true); // Reset. Calls inverted to go through (1,1) instead of (0,0)
990
- await this.setDTR(false);
991
- await this.setRTS(true); // RTS set as Windows only propagates DTR on RTS setting
992
- await this.sleep(100);
993
-
994
- await this.setDTR(false);
995
- await this.setRTS(false); // Chip out of reset
996
-
997
- // Wait for chip to boot into bootloader
998
- await this.sleep(200);
999
- }
1000
1914
 
1001
- /**
1002
- * @name hardResetClassic
1003
- * Classic reset sequence for USB-to-Serial bridge chips (CH340, CP2102, etc.)
1004
- */
1005
- async hardResetClassic() {
1006
- await this.setDTR(false); // IO0=HIGH
1007
- await this.setRTS(true); // EN=LOW, chip in reset
1008
- await this.sleep(100);
1009
- await this.setDTR(true); // IO0=LOW
1010
- await this.setRTS(false); // EN=HIGH, chip out of reset
1011
- await this.sleep(50);
1012
- await this.setDTR(false); // IO0=HIGH, done
1915
+ await sleep(SYNC_TIMEOUT);
1916
+ }
1013
1917
 
1014
- // Wait for chip to boot into bootloader
1015
- await this.sleep(200);
1918
+ return false;
1016
1919
  }
1017
1920
 
1018
1921
  /**
@@ -1022,7 +1925,7 @@ export class ESPLoader extends EventTarget {
1022
1925
  */
1023
1926
  async sync() {
1024
1927
  for (let i = 0; i < 5; i++) {
1025
- this._inputBuffer.length = 0;
1928
+ this._clearInputBuffer();
1026
1929
  const response = await this._sync();
1027
1930
  if (response) {
1028
1931
  await sleep(SYNC_TIMEOUT);
@@ -1041,14 +1944,17 @@ export class ESPLoader extends EventTarget {
1041
1944
  */
1042
1945
  async _sync() {
1043
1946
  await this.sendCommand(ESP_SYNC, SYNC_PACKET);
1947
+
1044
1948
  for (let i = 0; i < 8; i++) {
1045
1949
  try {
1046
1950
  const [, data] = await this.getResponse(ESP_SYNC, SYNC_TIMEOUT);
1047
1951
  if (data.length > 1 && data[0] == 0 && data[1] == 0) {
1048
1952
  return true;
1049
1953
  }
1050
- } catch {
1051
- // If read packet fails.
1954
+ } catch (e) {
1955
+ if (this.debug) {
1956
+ this.logger.debug(`Sync attempt ${i + 1} failed: ${e}`);
1957
+ }
1052
1958
  }
1053
1959
  }
1054
1960
  return false;
@@ -1692,13 +2598,21 @@ export class ESPLoader extends EventTarget {
1692
2598
  },
1693
2599
  async () => {
1694
2600
  // Previous write failed, but still attempt this write
2601
+ this.logger.debug(
2602
+ "Previous write failed, attempting recovery for current write",
2603
+ );
1695
2604
  if (!this.port.writable) {
1696
2605
  throw new Error("Port became unavailable during write");
1697
2606
  }
1698
2607
 
1699
2608
  // Writer was likely cleaned up by previous error, create new one
1700
2609
  if (!this._writer) {
1701
- this._writer = this.port.writable.getWriter();
2610
+ try {
2611
+ this._writer = this.port.writable.getWriter();
2612
+ } catch (err) {
2613
+ this.logger.debug(`Failed to get writer in recovery: ${err}`);
2614
+ throw new Error("Cannot acquire writer lock");
2615
+ }
1702
2616
  }
1703
2617
 
1704
2618
  await this._writer.write(new Uint8Array(data));
@@ -1710,7 +2624,7 @@ export class ESPLoader extends EventTarget {
1710
2624
  if (this._writer) {
1711
2625
  try {
1712
2626
  this._writer.releaseLock();
1713
- } catch (e) {
2627
+ } catch {
1714
2628
  // Ignore release errors
1715
2629
  }
1716
2630
  this._writer = undefined;
@@ -1733,47 +2647,73 @@ export class ESPLoader extends EventTarget {
1733
2647
  return;
1734
2648
  }
1735
2649
 
2650
+ // Wait for pending writes to complete
1736
2651
  try {
1737
- this._isReconfiguring = true;
2652
+ await this._writeChain;
2653
+ } catch (err) {
2654
+ this.logger.debug(`Pending write error during disconnect: ${err}`);
2655
+ }
1738
2656
 
1739
- // Wait for pending writes to complete
2657
+ // Release persistent writer before closing
2658
+ if (this._writer) {
1740
2659
  try {
1741
- await this._writeChain;
2660
+ await this._writer.close();
2661
+ this._writer.releaseLock();
2662
+ } catch (err) {
2663
+ this.logger.debug(`Writer close/release error: ${err}`);
2664
+ }
2665
+ this._writer = undefined;
2666
+ } else {
2667
+ // No persistent writer exists, close stream directly
2668
+ // This path is taken when no writes have been queued
2669
+ try {
2670
+ const writer = this.port.writable.getWriter();
2671
+ await writer.close();
2672
+ writer.releaseLock();
1742
2673
  } catch (err) {
1743
- this.logger.debug(`Pending write error during disconnect: ${err}`);
2674
+ this.logger.debug(`Direct writer close error: ${err}`);
1744
2675
  }
2676
+ }
1745
2677
 
1746
- // Release persistent writer before closing
1747
- if (this._writer) {
1748
- try {
1749
- await this._writer.close();
1750
- this._writer.releaseLock();
1751
- } catch (err) {
1752
- this.logger.debug(`Writer close/release error: ${err}`);
1753
- }
1754
- this._writer = undefined;
1755
- } else {
1756
- // No persistent writer exists, close stream directly
1757
- // This path is taken when no writes have been queued
1758
- try {
1759
- const writer = this.port.writable.getWriter();
1760
- await writer.close();
1761
- writer.releaseLock();
1762
- } catch (err) {
1763
- this.logger.debug(`Direct writer close error: ${err}`);
1764
- }
2678
+ await new Promise((resolve) => {
2679
+ if (!this._reader) {
2680
+ resolve(undefined);
2681
+ return;
1765
2682
  }
1766
2683
 
1767
- await new Promise((resolve) => {
1768
- if (!this._reader) {
2684
+ // Set a timeout to prevent hanging (important for node-usb)
2685
+ const timeout = setTimeout(() => {
2686
+ this.logger.debug("Disconnect timeout - forcing resolution");
2687
+ resolve(undefined);
2688
+ }, 1000);
2689
+
2690
+ this.addEventListener(
2691
+ "disconnect",
2692
+ () => {
2693
+ clearTimeout(timeout);
1769
2694
  resolve(undefined);
1770
- }
1771
- this.addEventListener("disconnect", resolve, { once: true });
1772
- this._reader!.cancel();
1773
- });
1774
- this.connected = false;
1775
- } finally {
1776
- this._isReconfiguring = false;
2695
+ },
2696
+ { once: true },
2697
+ );
2698
+
2699
+ // Only cancel if reader is still active
2700
+ try {
2701
+ this._reader.cancel();
2702
+ } catch (err) {
2703
+ this.logger.debug(`Reader cancel error: ${err}`);
2704
+ // Reader already released, resolve immediately
2705
+ clearTimeout(timeout);
2706
+ resolve(undefined);
2707
+ }
2708
+ });
2709
+ this.connected = false;
2710
+
2711
+ // Close the port (important for node-usb adapter)
2712
+ try {
2713
+ await this.port.close();
2714
+ this.logger.debug("Port closed successfully");
2715
+ } catch (err) {
2716
+ this.logger.debug(`Port close error: ${err}`);
1777
2717
  }
1778
2718
  }
1779
2719
 
@@ -1788,12 +2728,11 @@ export class ESPLoader extends EventTarget {
1788
2728
  }
1789
2729
 
1790
2730
  try {
1791
- this._isReconfiguring = true;
1792
-
1793
2731
  this.logger.log("Reconnecting serial port...");
1794
2732
 
1795
2733
  this.connected = false;
1796
2734
  this.__inputBuffer = [];
2735
+ this.__inputBufferReadIndex = 0;
1797
2736
 
1798
2737
  // Wait for pending writes to complete
1799
2738
  try {
@@ -1802,6 +2741,9 @@ export class ESPLoader extends EventTarget {
1802
2741
  this.logger.debug(`Pending write error during reconnect: ${err}`);
1803
2742
  }
1804
2743
 
2744
+ // Block new writes during port close/open
2745
+ this._isReconfiguring = true;
2746
+
1805
2747
  // Release persistent writer
1806
2748
  if (this._writer) {
1807
2749
  try {
@@ -1846,6 +2788,9 @@ export class ESPLoader extends EventTarget {
1846
2788
  );
1847
2789
  }
1848
2790
 
2791
+ // Port is now open and ready - allow writes for initialization
2792
+ this._isReconfiguring = false;
2793
+
1849
2794
  // Save chip info and flash size (no need to detect again)
1850
2795
  const savedChipFamily = this.chipFamily;
1851
2796
  const savedChipName = this.chipName;
@@ -1858,6 +2803,7 @@ export class ESPLoader extends EventTarget {
1858
2803
 
1859
2804
  if (!this._parent) {
1860
2805
  this.__inputBuffer = [];
2806
+ this.__inputBufferReadIndex = 0;
1861
2807
  this.__totalBytesRead = 0;
1862
2808
  this.readLoop();
1863
2809
  }
@@ -1895,13 +2841,16 @@ export class ESPLoader extends EventTarget {
1895
2841
  }
1896
2842
  }
1897
2843
 
1898
- // Copy stub state to this instance if we're a stub loader
1899
- if (this.IS_STUB) {
1900
- Object.assign(this, stubLoader);
1901
- }
2844
+ // The stub is now running on the chip
2845
+ // stubLoader has this instance as _parent, so all operations go through this
2846
+ // We just need to mark this instance as running stub code
2847
+ this.IS_STUB = true;
2848
+
1902
2849
  this.logger.debug("Reconnection successful");
1903
- } finally {
2850
+ } catch (err) {
2851
+ // Ensure flag is reset on error
1904
2852
  this._isReconfiguring = false;
2853
+ throw err;
1905
2854
  }
1906
2855
  }
1907
2856
 
@@ -1931,8 +2880,8 @@ export class ESPLoader extends EventTarget {
1931
2880
  const drainTimeout = 100; // Short timeout for draining
1932
2881
 
1933
2882
  while (drained < bytesToDrain && Date.now() - drainStart < drainTimeout) {
1934
- if (this._inputBuffer.length > 0) {
1935
- const byte = this._inputBuffer.shift();
2883
+ if (this._inputBufferAvailable > 0) {
2884
+ const byte = this._readByte();
1936
2885
  if (byte !== undefined) {
1937
2886
  drained++;
1938
2887
  }
@@ -1949,6 +2898,7 @@ export class ESPLoader extends EventTarget {
1949
2898
  // Final clear of application buffer
1950
2899
  if (!this._parent) {
1951
2900
  this.__inputBuffer = [];
2901
+ this.__inputBufferReadIndex = 0;
1952
2902
  }
1953
2903
  }
1954
2904
 
@@ -1961,6 +2911,7 @@ export class ESPLoader extends EventTarget {
1961
2911
  // Clear application buffer
1962
2912
  if (!this._parent) {
1963
2913
  this.__inputBuffer = [];
2914
+ this.__inputBufferReadIndex = 0;
1964
2915
  }
1965
2916
 
1966
2917
  // Wait for any pending data
@@ -1969,6 +2920,7 @@ export class ESPLoader extends EventTarget {
1969
2920
  // Final clear
1970
2921
  if (!this._parent) {
1971
2922
  this.__inputBuffer = [];
2923
+ this.__inputBufferReadIndex = 0;
1972
2924
  }
1973
2925
 
1974
2926
  this.logger.debug("Serial buffers flushed");
@@ -2004,7 +2956,38 @@ export class ESPLoader extends EventTarget {
2004
2956
  `Reading ${size} bytes from flash at address 0x${addr.toString(16)}...`,
2005
2957
  );
2006
2958
 
2007
- const CHUNK_SIZE = 0x10000; // 64KB chunks
2959
+ // Initialize adaptive speed multipliers for WebUSB devices
2960
+ if (this.isWebUSB()) {
2961
+ if (this._isCDCDevice) {
2962
+ // CDC devices (CH343): Start with maximum, adaptive adjustment enabled
2963
+ this._adaptiveBlockMultiplier = 8; // blockSize = 248 bytes
2964
+ this._adaptiveMaxInFlightMultiplier = 8; // maxInFlight = 248 bytes
2965
+ this._consecutiveSuccessfulChunks = 0;
2966
+ this.logger.debug(
2967
+ `CDC device - Initialized: blockMultiplier=${this._adaptiveBlockMultiplier}, maxInFlightMultiplier=${this._adaptiveMaxInFlightMultiplier}`,
2968
+ );
2969
+ } else {
2970
+ // Non-CDC devices (CH340, CP2102): Fixed values, no adaptive adjustment
2971
+ this._adaptiveBlockMultiplier = 1; // blockSize = 31 bytes (fixed)
2972
+ this._adaptiveMaxInFlightMultiplier = 1; // maxInFlight = 31 bytes (fixed)
2973
+ this._consecutiveSuccessfulChunks = 0;
2974
+ this.logger.debug(
2975
+ `Non-CDC device - Fixed values: blockSize=31, maxInFlight=31`,
2976
+ );
2977
+ }
2978
+ }
2979
+
2980
+ // Chunk size: Amount of data to request from ESP in one command
2981
+ // For WebUSB (Android), use smaller chunks to avoid timeouts and buffer issues
2982
+ // For Web Serial (Desktop), use larger chunks for better performance
2983
+ let CHUNK_SIZE: number;
2984
+ if (this.isWebUSB()) {
2985
+ // WebUSB: Use smaller chunks to avoid SLIP timeout issues
2986
+ CHUNK_SIZE = 0x4 * 0x1000; // 4KB = 16384 bytes
2987
+ } else {
2988
+ // Web Serial: Use larger chunks for better performance
2989
+ CHUNK_SIZE = 0x40 * 0x1000;
2990
+ }
2008
2991
 
2009
2992
  let allData = new Uint8Array(0);
2010
2993
  let currentAddr = addr;
@@ -2014,11 +2997,13 @@ export class ESPLoader extends EventTarget {
2014
2997
  const chunkSize = Math.min(CHUNK_SIZE, remainingSize);
2015
2998
  let chunkSuccess = false;
2016
2999
  let retryCount = 0;
2017
- const MAX_RETRIES = 15;
3000
+ const MAX_RETRIES = 5;
3001
+ let deepRecoveryAttempted = false;
2018
3002
 
2019
3003
  // Retry loop for this chunk
2020
3004
  while (!chunkSuccess && retryCount <= MAX_RETRIES) {
2021
3005
  let resp = new Uint8Array(0);
3006
+ let lastAckedLength = 0; // Track last acknowledged length
2022
3007
 
2023
3008
  try {
2024
3009
  // Only log on first attempt or retries
@@ -2028,9 +3013,34 @@ export class ESPLoader extends EventTarget {
2028
3013
  );
2029
3014
  }
2030
3015
 
2031
- // Send read flash command for this chunk
2032
- // This must be inside the retry loop so we send a fresh command after errors
2033
- const pkt = pack("<IIII", currentAddr, chunkSize, 0x1000, 1024);
3016
+ let blockSize: number;
3017
+ let maxInFlight: number;
3018
+
3019
+ if (this.isWebUSB()) {
3020
+ // WebUSB (Android): All devices use adaptive speed
3021
+ // All have maxTransferSize=64, baseBlockSize=31
3022
+ const maxTransferSize =
3023
+ (this.port as WebUSBSerialPort).maxTransferSize || 64;
3024
+ const baseBlockSize = Math.floor((maxTransferSize - 2) / 2); // 31 bytes
3025
+
3026
+ // Use current adaptive multipliers (initialized at start of readFlash)
3027
+ blockSize = baseBlockSize * this._adaptiveBlockMultiplier;
3028
+ maxInFlight = baseBlockSize * this._adaptiveMaxInFlightMultiplier;
3029
+ } else {
3030
+ // Web Serial (Desktop): Use multiples of 63 for consistency
3031
+ const base = 63;
3032
+ blockSize = base * 65; // 63 * 65 = 4095 (close to 0x1000)
3033
+ maxInFlight = base * 130; // 63 * 130 = 8190 (close to blockSize * 2)
3034
+ }
3035
+
3036
+ const pkt = pack(
3037
+ "<IIII",
3038
+ currentAddr,
3039
+ chunkSize,
3040
+ blockSize,
3041
+ maxInFlight,
3042
+ );
3043
+
2034
3044
  const [res] = await this.checkCommand(ESP_READ_FLASH, pkt);
2035
3045
 
2036
3046
  if (res != 0) {
@@ -2082,10 +3092,22 @@ export class ESPLoader extends EventTarget {
2082
3092
  newResp.set(packetData, resp.length);
2083
3093
  resp = newResp;
2084
3094
 
2085
- // Send acknowledgment
2086
- const ackData = pack("<I", resp.length);
2087
- const slipEncodedAck = slipEncode(ackData);
2088
- await this.writeToStream(slipEncodedAck);
3095
+ // Send acknowledgment when we've received maxInFlight bytes
3096
+ // The stub sends packets until (num_sent - num_acked) >= max_in_flight
3097
+ // We MUST wait for all packets before sending ACK
3098
+ const shouldAck =
3099
+ resp.length >= chunkSize || // End of chunk
3100
+ resp.length >= lastAckedLength + maxInFlight; // Received all packets
3101
+
3102
+ if (shouldAck) {
3103
+ const ackData = pack("<I", resp.length);
3104
+ const slipEncodedAck = slipEncode(ackData);
3105
+ await this.writeToStream(slipEncodedAck);
3106
+
3107
+ // Update lastAckedLength to current response length
3108
+ // This ensures next ACK is sent at the right time
3109
+ lastAckedLength = resp.length;
3110
+ }
2089
3111
  }
2090
3112
  }
2091
3113
 
@@ -2096,9 +3118,93 @@ export class ESPLoader extends EventTarget {
2096
3118
  allData = newAllData;
2097
3119
 
2098
3120
  chunkSuccess = true;
3121
+
3122
+ // ADAPTIVE SPEED ADJUSTMENT: Only for CDC devices
3123
+ // Non-CDC devices (CH340, CP2102) stay at fixed blockSize=31, maxInFlight=31
3124
+ if (this.isWebUSB() && this._isCDCDevice && retryCount === 0) {
3125
+ this._consecutiveSuccessfulChunks++;
3126
+
3127
+ // After 2 consecutive successful chunks, increase speed gradually
3128
+ if (this._consecutiveSuccessfulChunks >= 2) {
3129
+ const maxTransferSize =
3130
+ (this.port as WebUSBSerialPort).maxTransferSize || 64;
3131
+ const baseBlockSize = Math.floor((maxTransferSize - 2) / 2); // 31 bytes
3132
+
3133
+ // Maximum: blockSize=248 (8 * 31), maxInFlight=248 (8 * 31)
3134
+ const MAX_BLOCK_MULTIPLIER = 8; // 248 bytes - tested stable
3135
+ const MAX_INFLIGHT_MULTIPLIER = 8; // 248 bytes - tested stable
3136
+
3137
+ let adjusted = false;
3138
+
3139
+ // Increase blockSize first (up to 248), then maxInFlight
3140
+ if (this._adaptiveBlockMultiplier < MAX_BLOCK_MULTIPLIER) {
3141
+ this._adaptiveBlockMultiplier = Math.min(
3142
+ this._adaptiveBlockMultiplier * 2,
3143
+ MAX_BLOCK_MULTIPLIER,
3144
+ );
3145
+ adjusted = true;
3146
+ }
3147
+ // Once blockSize is at maximum, increase maxInFlight
3148
+ else if (
3149
+ this._adaptiveMaxInFlightMultiplier < MAX_INFLIGHT_MULTIPLIER
3150
+ ) {
3151
+ this._adaptiveMaxInFlightMultiplier = Math.min(
3152
+ this._adaptiveMaxInFlightMultiplier * 2,
3153
+ MAX_INFLIGHT_MULTIPLIER,
3154
+ );
3155
+ adjusted = true;
3156
+ }
3157
+
3158
+ if (adjusted) {
3159
+ const newBlockSize =
3160
+ baseBlockSize * this._adaptiveBlockMultiplier;
3161
+ const newMaxInFlight =
3162
+ baseBlockSize * this._adaptiveMaxInFlightMultiplier;
3163
+ this.logger.debug(
3164
+ `Speed increased: blockSize=${newBlockSize}, maxInFlight=${newMaxInFlight}`,
3165
+ );
3166
+ this._lastAdaptiveAdjustment = Date.now();
3167
+ }
3168
+
3169
+ // Reset counter
3170
+ this._consecutiveSuccessfulChunks = 0;
3171
+ }
3172
+ }
2099
3173
  } catch (err) {
2100
3174
  retryCount++;
2101
3175
 
3176
+ // ADAPTIVE SPEED ADJUSTMENT: Only for CDC devices
3177
+ // Non-CDC devices stay at fixed values
3178
+ if (this.isWebUSB() && this._isCDCDevice && retryCount === 1) {
3179
+ // Only reduce if we're above minimum
3180
+ if (
3181
+ this._adaptiveBlockMultiplier > 1 ||
3182
+ this._adaptiveMaxInFlightMultiplier > 1
3183
+ ) {
3184
+ // Reduce to minimum on error
3185
+ this._adaptiveBlockMultiplier = 1; // 31 bytes (for CH343)
3186
+ this._adaptiveMaxInFlightMultiplier = 1; // 31 bytes
3187
+ this._consecutiveSuccessfulChunks = 0; // Reset success counter
3188
+
3189
+ const maxTransferSize =
3190
+ (this.port as WebUSBSerialPort).maxTransferSize || 64;
3191
+ const baseBlockSize = Math.floor((maxTransferSize - 2) / 2);
3192
+ const newBlockSize =
3193
+ baseBlockSize * this._adaptiveBlockMultiplier;
3194
+ const newMaxInFlight =
3195
+ baseBlockSize * this._adaptiveMaxInFlightMultiplier;
3196
+
3197
+ this.logger.debug(
3198
+ `Error at higher speed - reduced to minimum: blockSize=${newBlockSize}, maxInFlight=${newMaxInFlight}`,
3199
+ );
3200
+ } else {
3201
+ // Already at minimum and still failing - this is a real error
3202
+ this.logger.debug(
3203
+ `Error at minimum speed (blockSize=31, maxInFlight=31) - not a speed issue`,
3204
+ );
3205
+ }
3206
+ }
3207
+
2102
3208
  // Check if it's a timeout error or SLIP error
2103
3209
  if (err instanceof SlipReadError) {
2104
3210
  if (retryCount <= MAX_RETRIES) {
@@ -2120,9 +3226,37 @@ export class ESPLoader extends EventTarget {
2120
3226
  this.logger.debug(`Buffer drain error: ${drainErr}`);
2121
3227
  }
2122
3228
  } else {
2123
- throw new Error(
2124
- `Failed to read chunk at 0x${currentAddr.toString(16)} after ${MAX_RETRIES} retries: ${err}`,
2125
- );
3229
+ // All retries exhausted - attempt recovery by reloading stub
3230
+ // IMPORTANT: Do NOT close port to keep ESP32 in bootloader mode
3231
+ if (!deepRecoveryAttempted) {
3232
+ deepRecoveryAttempted = true;
3233
+
3234
+ this.logger.log(
3235
+ `All retries exhausted at 0x${currentAddr.toString(16)}. Attempting recovery (close and reopen port)...`,
3236
+ );
3237
+
3238
+ try {
3239
+ // Reconnect will close port, reopen, and reload stub
3240
+ await this.reconnect();
3241
+
3242
+ this.logger.log(
3243
+ "Deep recovery successful. Resuming read from current position...",
3244
+ );
3245
+
3246
+ // Reset retry counter to give it another chance after recovery
3247
+ retryCount = 0;
3248
+ continue;
3249
+ } catch (recoveryErr) {
3250
+ throw new Error(
3251
+ `Failed to read chunk at 0x${currentAddr.toString(16)} after ${MAX_RETRIES} retries and recovery failed: ${recoveryErr}`,
3252
+ );
3253
+ }
3254
+ } else {
3255
+ // Recovery already attempted, give up
3256
+ throw new Error(
3257
+ `Failed to read chunk at 0x${currentAddr.toString(16)} after ${MAX_RETRIES} retries and recovery attempt`,
3258
+ );
3259
+ }
2126
3260
  }
2127
3261
  } else {
2128
3262
  // Non-SLIP error, don't retry
@@ -2144,7 +3278,6 @@ export class ESPLoader extends EventTarget {
2144
3278
  );
2145
3279
  }
2146
3280
 
2147
- this.logger.debug(`Successfully read ${allData.length} bytes from flash`);
2148
3281
  return allData;
2149
3282
  }
2150
3283
  }
@@ -2205,10 +3338,61 @@ class EspStubLoader extends ESPLoader {
2205
3338
  }
2206
3339
 
2207
3340
  /**
2208
- * @name getEraseSize
2209
- * depending on flash chip model the erase may take this long (maybe longer!)
3341
+ * @name eraseFlash
3342
+ * Erase entire flash chip
2210
3343
  */
2211
3344
  async eraseFlash() {
2212
3345
  await this.checkCommand(ESP_ERASE_FLASH, [], 0, CHIP_ERASE_TIMEOUT);
2213
3346
  }
3347
+
3348
+ /**
3349
+ * @name eraseRegion
3350
+ * Erase a specific region of flash
3351
+ */
3352
+ async eraseRegion(offset: number, size: number) {
3353
+ // Validate inputs
3354
+ if (offset < 0) {
3355
+ throw new Error(`Invalid offset: ${offset} (must be non-negative)`);
3356
+ }
3357
+ if (size < 0) {
3358
+ throw new Error(`Invalid size: ${size} (must be non-negative)`);
3359
+ }
3360
+
3361
+ // No-op for zero size
3362
+ if (size === 0) {
3363
+ this.logger.log("eraseRegion: size is 0, skipping erase");
3364
+ return;
3365
+ }
3366
+
3367
+ // Check for sector alignment
3368
+ if (offset % FLASH_SECTOR_SIZE !== 0) {
3369
+ throw new Error(
3370
+ `Offset ${offset} (0x${offset.toString(16)}) is not aligned to flash sector size ${FLASH_SECTOR_SIZE} (0x${FLASH_SECTOR_SIZE.toString(16)})`,
3371
+ );
3372
+ }
3373
+ if (size % FLASH_SECTOR_SIZE !== 0) {
3374
+ throw new Error(
3375
+ `Size ${size} (0x${size.toString(16)}) is not aligned to flash sector size ${FLASH_SECTOR_SIZE} (0x${FLASH_SECTOR_SIZE.toString(16)})`,
3376
+ );
3377
+ }
3378
+
3379
+ // Check for reasonable bounds (prevent wrapping in pack)
3380
+ const maxValue = 0xffffffff; // 32-bit unsigned max
3381
+ if (offset > maxValue) {
3382
+ throw new Error(`Offset ${offset} exceeds maximum value ${maxValue}`);
3383
+ }
3384
+ if (size > maxValue) {
3385
+ throw new Error(`Size ${size} exceeds maximum value ${maxValue}`);
3386
+ }
3387
+ // Check for wrap-around
3388
+ if (offset + size > maxValue) {
3389
+ throw new Error(
3390
+ `Region end (offset + size = ${offset + size}) exceeds maximum addressable range ${maxValue}`,
3391
+ );
3392
+ }
3393
+
3394
+ const timeout = timeoutPerMb(ERASE_REGION_TIMEOUT_PER_MB, size);
3395
+ const buffer = pack("<II", offset, size);
3396
+ await this.checkCommand(ESP_ERASE_REGION, buffer, 0, timeout);
3397
+ }
2214
3398
  }