esp32tool 1.1.9 → 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 +126 -20
  11. package/dist/esp_loader.js +1190 -230
  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 +1392 -261
  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;
@@ -87,6 +99,14 @@ export class ESPLoader extends EventTarget {
87
99
  private _initializationSucceeded: boolean = false;
88
100
  private __commandLock: Promise<[number, number[]]> = Promise.resolve([0, []]);
89
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 {
@@ -140,6 +261,88 @@ export class ESPLoader extends EventTarget {
140
261
  }
141
262
  }
142
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
+
143
346
  private detectUSBSerialChip(
144
347
  vendorId: number,
145
348
  productId: number,
@@ -196,6 +399,7 @@ export class ESPLoader extends EventTarget {
196
399
  async initialize() {
197
400
  if (!this._parent) {
198
401
  this.__inputBuffer = [];
402
+ this.__inputBufferReadIndex = 0;
199
403
  this.__totalBytesRead = 0;
200
404
 
201
405
  // Detect and log USB-Serial chip info
@@ -216,6 +420,15 @@ export class ESPLoader extends EventTarget {
216
420
  if (portInfo.usbVendorId === 0x303a && portInfo.usbProductId === 0x2) {
217
421
  this._isESP32S2NativeUSB = true;
218
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
+ }
219
432
  }
220
433
 
221
434
  // Don't await this promise so it doesn't block rest of method.
@@ -291,7 +504,7 @@ export class ESPLoader extends EventTarget {
291
504
  await this.drainInputBuffer(200);
292
505
 
293
506
  // Clear input buffer and re-sync to recover from failed command
294
- this._inputBuffer.length = 0;
507
+ this._clearInputBuffer();
295
508
  await sleep(SYNC_TIMEOUT);
296
509
 
297
510
  // Re-sync with the chip to ensure clean communication
@@ -407,6 +620,21 @@ export class ESPLoader extends EventTarget {
407
620
  };
408
621
  }
409
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
+
410
638
  /**
411
639
  * @name readLoop
412
640
  * Reads data from the input stream and places it in the inputBuffer
@@ -441,7 +669,13 @@ export class ESPLoader extends EventTarget {
441
669
  }
442
670
  } catch {
443
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;
444
677
  }
678
+
445
679
  // Disconnected!
446
680
  this.connected = false;
447
681
 
@@ -467,6 +701,12 @@ export class ESPLoader extends EventTarget {
467
701
  }
468
702
 
469
703
  state_DTR = false;
704
+ state_RTS = false;
705
+
706
+ // ============================================================================
707
+ // Web Serial (Desktop) - DTR/RTS Signal Handling & Reset Strategies
708
+ // ============================================================================
709
+
470
710
  async setRTS(state: boolean) {
471
711
  await this.port.setSignals({ requestToSend: state });
472
712
  // Work-around for adapters on Windows using the usbser.sys driver:
@@ -481,6 +721,579 @@ export class ESPLoader extends EventTarget {
481
721
  await this.port.setSignals({ dataTerminalReady: state });
482
722
  }
483
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
+
484
1297
  async hardReset(bootloader = false) {
485
1298
  if (bootloader) {
486
1299
  // enter flash mode
@@ -488,15 +1301,31 @@ export class ESPLoader extends EventTarget {
488
1301
  await this.hardResetUSBJTAGSerial();
489
1302
  this.logger.log("USB-JTAG/Serial reset.");
490
1303
  } else {
491
- await this.hardResetClassic();
492
- 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
+ }
493
1312
  }
494
1313
  } else {
495
- // just reset
496
- await this.setRTS(true); // EN->LOW
497
- await this.sleep(100);
498
- await this.setRTS(false);
499
- 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
+ }
500
1329
  }
501
1330
  await new Promise((resolve) => setTimeout(resolve, 1000));
502
1331
  }
@@ -627,6 +1456,13 @@ export class ESPLoader extends EventTarget {
627
1456
  statusLen = 4;
628
1457
  } else if ([2, 4].includes(data.length)) {
629
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
+ );
630
1466
  }
631
1467
  }
632
1468
 
@@ -670,6 +1506,7 @@ export class ESPLoader extends EventTarget {
670
1506
  ...pack("<BBHI", 0x00, opcode, buffer.length, checksum),
671
1507
  ...buffer,
672
1508
  ]);
1509
+
673
1510
  if (this.debug) {
674
1511
  this.logger.debug(
675
1512
  `Writing ${packet.length} byte${packet.length == 1 ? "" : "s"}:`,
@@ -683,81 +1520,188 @@ export class ESPLoader extends EventTarget {
683
1520
  * @name readPacket
684
1521
  * Generator to read SLIP packets from a serial port.
685
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
686
1527
  */
687
-
688
1528
  async readPacket(timeout: number): Promise<number[]> {
689
1529
  let partialPacket: number[] | null = null;
690
1530
  let inEscape = false;
691
- let readBytes: number[] = [];
692
- while (true) {
693
- const stamp = Date.now();
694
- readBytes = [];
695
- while (Date.now() - stamp < timeout) {
696
- if (this._inputBuffer.length > 0) {
697
- readBytes.push(this._inputBuffer.shift()!);
698
- break;
699
- } else {
700
- // Reduced sleep time for faster response during high-speed transfers
1531
+
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
+ }
1545
+
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
+ }
1551
+
1552
+ // If no data available, wait a bit
1553
+ if (this._inputBufferAvailable === 0) {
701
1554
  await sleep(1);
1555
+ continue;
1556
+ }
1557
+
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()!;
1569
+
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)
1611
+ this.logger.debug(
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
+ }
702
1621
  }
703
1622
  }
704
- if (readBytes.length == 0) {
705
- const waitingFor = partialPacket === null ? "header" : "content";
706
- throw new SlipReadError("Timed out waiting for packet " + waitingFor);
707
- }
708
- if (this.debug)
709
- this.logger.debug(
710
- "Read " + readBytes.length + " bytes: " + hexFormatter(readBytes),
711
- );
712
- for (const b of readBytes) {
713
- if (partialPacket === null) {
714
- // waiting for packet header
715
- if (b == 0xc0) {
716
- partialPacket = [];
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;
717
1640
  } else {
718
- if (this.debug) {
719
- this.logger.debug("Read invalid data: " + toHex(b));
720
- this.logger.debug(
721
- "Remaining data in serial buffer: " +
722
- hexFormatter(this._inputBuffer),
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) + ")",
723
1668
  );
724
1669
  }
725
- throw new SlipReadError(
726
- "Invalid head of packet (" + toHex(b) + ")",
727
- );
728
- }
729
- } else if (inEscape) {
730
- // part-way through escape sequence
731
- inEscape = false;
732
- if (b == 0xdc) {
733
- partialPacket.push(0xc0);
734
- } else if (b == 0xdd) {
735
- partialPacket.push(0xdb);
736
- } else {
737
- if (this.debug) {
738
- this.logger.debug("Read invalid data: " + toHex(b));
739
- this.logger.debug(
740
- "Remaining data in serial buffer: " +
741
- 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) + ")",
742
1687
  );
743
1688
  }
744
- throw new SlipReadError(
745
- "Invalid SLIP escape (0xdb, " + toHex(b) + ")",
746
- );
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);
747
1704
  }
748
- } else if (b == 0xdb) {
749
- // start of escape sequence
750
- inEscape = true;
751
- } else if (b == 0xc0) {
752
- // end of packet
753
- if (this.debug)
754
- this.logger.debug(
755
- "Received full packet: " + hexFormatter(partialPacket),
756
- );
757
- return partialPacket;
758
- } else {
759
- // normal byte in packet
760
- partialPacket.push(b);
761
1705
  }
762
1706
  }
763
1707
  }
@@ -781,6 +1725,7 @@ export class ESPLoader extends EventTarget {
781
1725
  }
782
1726
 
783
1727
  const [resp, opRet, , val] = unpack("<BBHI", packet.slice(0, 8));
1728
+
784
1729
  if (resp != 1) {
785
1730
  continue;
786
1731
  }
@@ -795,7 +1740,7 @@ export class ESPLoader extends EventTarget {
795
1740
  throw new Error(`Invalid (unsupported) command ${toHex(opcode)}`);
796
1741
  }
797
1742
  }
798
- throw "Response doesn't match request";
1743
+ throw new Error("Response doesn't match request");
799
1744
  }
800
1745
 
801
1746
  /**
@@ -858,6 +1803,9 @@ export class ESPLoader extends EventTarget {
858
1803
  }
859
1804
 
860
1805
  async reconfigurePort(baud: number) {
1806
+ // Block new writes during the entire reconfiguration (all paths)
1807
+ this._isReconfiguring = true;
1808
+
861
1809
  try {
862
1810
  // Wait for pending writes to complete
863
1811
  try {
@@ -866,9 +1814,35 @@ export class ESPLoader extends EventTarget {
866
1814
  this.logger.debug(`Pending write error during reconfigure: ${err}`);
867
1815
  }
868
1816
 
869
- // Block new writes during port close/open
870
- this._isReconfiguring = true;
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
+ }
871
1844
 
1845
+ // Web Serial or CH343: Close and reopen port
872
1846
  // Release persistent writer before closing
873
1847
  if (this._writer) {
874
1848
  try {
@@ -889,148 +1863,59 @@ export class ESPLoader extends EventTarget {
889
1863
  // Reopen Port
890
1864
  await this.port.open({ baudRate: baud });
891
1865
 
892
- // Port is now open - allow writes again
893
- this._isReconfiguring = false;
894
-
895
1866
  // Clear buffer again
896
1867
  await this.flushSerialBuffers();
897
1868
 
898
1869
  // Restart Readloop
899
1870
  this.readLoop();
900
1871
  } catch (e) {
901
- this._isReconfiguring = false;
902
1872
  this.logger.error(`Reconfigure port error: ${e}`);
903
1873
  throw new Error(`Unable to change the baud rate to ${baud}: ${e}`);
1874
+ } finally {
1875
+ // Always reset flag, even on error or early return
1876
+ this._isReconfiguring = false;
904
1877
  }
905
1878
  }
906
1879
 
907
1880
  /**
908
- * @name connectWithResetStrategies
909
- * Try different reset strategies to enter bootloader mode
910
- * 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
911
1884
  */
912
- async connectWithResetStrategies() {
913
- const portInfo = this.port.getInfo();
914
- const isUSBJTAGSerial = portInfo.usbProductId === USB_JTAG_SERIAL_PID;
915
- const isEspressifUSB = portInfo.usbVendorId === 0x303a;
916
-
917
- this.logger.log(
918
- `Detected USB: VID=0x${portInfo.usbVendorId?.toString(16) || "unknown"}, PID=0x${portInfo.usbProductId?.toString(16) || "unknown"}`,
919
- );
920
-
921
- // Define reset strategies to try in order
922
- const resetStrategies: Array<{ name: string; fn: () => Promise<void> }> =
923
- [];
924
-
925
- // Strategy 1: USB-JTAG/Serial reset (for ESP32-C3, C6, S3, etc.)
926
- // Try this first if we detect Espressif USB VID or the specific PID
927
- if (isUSBJTAGSerial || isEspressifUSB) {
928
- resetStrategies.push({
929
- name: "USB-JTAG/Serial",
930
- fn: async () => await this.hardResetUSBJTAGSerial(),
931
- });
932
- }
1885
+ async syncWithTimeout(timeoutMs: number): Promise<boolean> {
1886
+ const startTime = Date.now();
933
1887
 
934
- // Strategy 2: Classic reset (for USB-to-Serial bridges)
935
- resetStrategies.push({
936
- name: "Classic",
937
- fn: async () => await this.hardResetClassic(),
938
- });
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
+ }
939
1893
 
940
- // Strategy 3: If USB-JTAG/Serial was not tried yet, try it as fallback
941
- if (!isUSBJTAGSerial && !isEspressifUSB) {
942
- resetStrategies.push({
943
- name: "USB-JTAG/Serial (fallback)",
944
- fn: async () => await this.hardResetUSBJTAGSerial(),
945
- });
946
- }
1894
+ // Check abandon flag
1895
+ if (this._abandonCurrentOperation) {
1896
+ return false;
1897
+ }
947
1898
 
948
- let lastError: Error | null = null;
1899
+ this._clearInputBuffer();
949
1900
 
950
- // Try each reset strategy
951
- for (const strategy of resetStrategies) {
952
1901
  try {
953
- this.logger.log(`Trying ${strategy.name} reset...`);
954
-
955
- // Check if port is still open, if not, skip this strategy
956
- if (!this.connected || !this.port.writable) {
957
- this.logger.log(`Port disconnected, skipping ${strategy.name} reset`);
958
- continue;
1902
+ const response = await this._sync();
1903
+ if (response) {
1904
+ await sleep(SYNC_TIMEOUT);
1905
+ return true;
959
1906
  }
960
-
961
- await strategy.fn();
962
-
963
- // Try to sync after reset
964
- await this.sync();
965
-
966
- // If we get here, sync succeeded
967
- this.logger.log(`Connected successfully with ${strategy.name} reset.`);
968
- return;
969
- } catch (error) {
970
- lastError = error as Error;
971
- this.logger.log(
972
- `${strategy.name} reset failed: ${(error as Error).message}`,
973
- );
974
-
975
- // If port got disconnected, we can't try more strategies
976
- if (!this.connected || !this.port.writable) {
977
- this.logger.log(`Port disconnected during reset attempt`);
978
- 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;
979
1912
  }
980
-
981
- // Clear buffers before trying next strategy
982
- this._inputBuffer.length = 0;
983
- await this.drainInputBuffer(200);
984
- await this.flushSerialBuffers();
985
1913
  }
986
- }
987
-
988
- // All strategies failed
989
- throw new Error(
990
- `Couldn't sync to ESP. Try resetting manually. Last error: ${lastError?.message}`,
991
- );
992
- }
993
-
994
- /**
995
- * @name hardResetUSBJTAGSerial
996
- * USB-JTAG/Serial reset sequence for ESP32-C3, ESP32-S3, ESP32-C6, etc.
997
- */
998
- async hardResetUSBJTAGSerial() {
999
- await this.setRTS(false);
1000
- await this.setDTR(false); // Idle
1001
- await this.sleep(100);
1002
-
1003
- await this.setDTR(true); // Set IO0
1004
- await this.setRTS(false);
1005
- await this.sleep(100);
1006
-
1007
- await this.setRTS(true); // Reset. Calls inverted to go through (1,1) instead of (0,0)
1008
- await this.setDTR(false);
1009
- await this.setRTS(true); // RTS set as Windows only propagates DTR on RTS setting
1010
- await this.sleep(100);
1011
-
1012
- await this.setDTR(false);
1013
- await this.setRTS(false); // Chip out of reset
1014
1914
 
1015
- // Wait for chip to boot into bootloader
1016
- await this.sleep(200);
1017
- }
1018
-
1019
- /**
1020
- * @name hardResetClassic
1021
- * Classic reset sequence for USB-to-Serial bridge chips (CH340, CP2102, etc.)
1022
- */
1023
- async hardResetClassic() {
1024
- await this.setDTR(false); // IO0=HIGH
1025
- await this.setRTS(true); // EN=LOW, chip in reset
1026
- await this.sleep(100);
1027
- await this.setDTR(true); // IO0=LOW
1028
- await this.setRTS(false); // EN=HIGH, chip out of reset
1029
- await this.sleep(50);
1030
- await this.setDTR(false); // IO0=HIGH, done
1915
+ await sleep(SYNC_TIMEOUT);
1916
+ }
1031
1917
 
1032
- // Wait for chip to boot into bootloader
1033
- await this.sleep(200);
1918
+ return false;
1034
1919
  }
1035
1920
 
1036
1921
  /**
@@ -1040,7 +1925,7 @@ export class ESPLoader extends EventTarget {
1040
1925
  */
1041
1926
  async sync() {
1042
1927
  for (let i = 0; i < 5; i++) {
1043
- this._inputBuffer.length = 0;
1928
+ this._clearInputBuffer();
1044
1929
  const response = await this._sync();
1045
1930
  if (response) {
1046
1931
  await sleep(SYNC_TIMEOUT);
@@ -1059,14 +1944,17 @@ export class ESPLoader extends EventTarget {
1059
1944
  */
1060
1945
  async _sync() {
1061
1946
  await this.sendCommand(ESP_SYNC, SYNC_PACKET);
1947
+
1062
1948
  for (let i = 0; i < 8; i++) {
1063
1949
  try {
1064
1950
  const [, data] = await this.getResponse(ESP_SYNC, SYNC_TIMEOUT);
1065
1951
  if (data.length > 1 && data[0] == 0 && data[1] == 0) {
1066
1952
  return true;
1067
1953
  }
1068
- } catch {
1069
- // If read packet fails.
1954
+ } catch (e) {
1955
+ if (this.debug) {
1956
+ this.logger.debug(`Sync attempt ${i + 1} failed: ${e}`);
1957
+ }
1070
1958
  }
1071
1959
  }
1072
1960
  return false;
@@ -1710,13 +2598,21 @@ export class ESPLoader extends EventTarget {
1710
2598
  },
1711
2599
  async () => {
1712
2600
  // Previous write failed, but still attempt this write
2601
+ this.logger.debug(
2602
+ "Previous write failed, attempting recovery for current write",
2603
+ );
1713
2604
  if (!this.port.writable) {
1714
2605
  throw new Error("Port became unavailable during write");
1715
2606
  }
1716
2607
 
1717
2608
  // Writer was likely cleaned up by previous error, create new one
1718
2609
  if (!this._writer) {
1719
- 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
+ }
1720
2616
  }
1721
2617
 
1722
2618
  await this._writer.write(new Uint8Array(data));
@@ -1728,7 +2624,7 @@ export class ESPLoader extends EventTarget {
1728
2624
  if (this._writer) {
1729
2625
  try {
1730
2626
  this._writer.releaseLock();
1731
- } catch (e) {
2627
+ } catch {
1732
2628
  // Ignore release errors
1733
2629
  }
1734
2630
  this._writer = undefined;
@@ -1751,48 +2647,73 @@ export class ESPLoader extends EventTarget {
1751
2647
  return;
1752
2648
  }
1753
2649
 
2650
+ // Wait for pending writes to complete
1754
2651
  try {
1755
- // Wait for pending writes to complete
2652
+ await this._writeChain;
2653
+ } catch (err) {
2654
+ this.logger.debug(`Pending write error during disconnect: ${err}`);
2655
+ }
2656
+
2657
+ // Release persistent writer before closing
2658
+ if (this._writer) {
1756
2659
  try {
1757
- await this._writeChain;
2660
+ await this._writer.close();
2661
+ this._writer.releaseLock();
1758
2662
  } catch (err) {
1759
- this.logger.debug(`Pending write error during disconnect: ${err}`);
2663
+ this.logger.debug(`Writer close/release error: ${err}`);
1760
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();
2673
+ } catch (err) {
2674
+ this.logger.debug(`Direct writer close error: ${err}`);
2675
+ }
2676
+ }
1761
2677
 
1762
- // Block new writes during disconnect
1763
- this._isReconfiguring = true;
1764
-
1765
- // Release persistent writer before closing
1766
- if (this._writer) {
1767
- try {
1768
- await this._writer.close();
1769
- this._writer.releaseLock();
1770
- } catch (err) {
1771
- this.logger.debug(`Writer close/release error: ${err}`);
1772
- }
1773
- this._writer = undefined;
1774
- } else {
1775
- // No persistent writer exists, close stream directly
1776
- // This path is taken when no writes have been queued
1777
- try {
1778
- const writer = this.port.writable.getWriter();
1779
- await writer.close();
1780
- writer.releaseLock();
1781
- } catch (err) {
1782
- this.logger.debug(`Direct writer close error: ${err}`);
1783
- }
2678
+ await new Promise((resolve) => {
2679
+ if (!this._reader) {
2680
+ resolve(undefined);
2681
+ return;
1784
2682
  }
1785
2683
 
1786
- await new Promise((resolve) => {
1787
- 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);
1788
2694
  resolve(undefined);
1789
- }
1790
- this.addEventListener("disconnect", resolve, { once: true });
1791
- this._reader!.cancel();
1792
- });
1793
- this.connected = false;
1794
- } finally {
1795
- 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}`);
1796
2717
  }
1797
2718
  }
1798
2719
 
@@ -1811,6 +2732,7 @@ export class ESPLoader extends EventTarget {
1811
2732
 
1812
2733
  this.connected = false;
1813
2734
  this.__inputBuffer = [];
2735
+ this.__inputBufferReadIndex = 0;
1814
2736
 
1815
2737
  // Wait for pending writes to complete
1816
2738
  try {
@@ -1881,6 +2803,7 @@ export class ESPLoader extends EventTarget {
1881
2803
 
1882
2804
  if (!this._parent) {
1883
2805
  this.__inputBuffer = [];
2806
+ this.__inputBufferReadIndex = 0;
1884
2807
  this.__totalBytesRead = 0;
1885
2808
  this.readLoop();
1886
2809
  }
@@ -1918,10 +2841,11 @@ export class ESPLoader extends EventTarget {
1918
2841
  }
1919
2842
  }
1920
2843
 
1921
- // Copy stub state to this instance if we're a stub loader
1922
- if (this.IS_STUB) {
1923
- Object.assign(this, stubLoader);
1924
- }
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
+
1925
2849
  this.logger.debug("Reconnection successful");
1926
2850
  } catch (err) {
1927
2851
  // Ensure flag is reset on error
@@ -1956,8 +2880,8 @@ export class ESPLoader extends EventTarget {
1956
2880
  const drainTimeout = 100; // Short timeout for draining
1957
2881
 
1958
2882
  while (drained < bytesToDrain && Date.now() - drainStart < drainTimeout) {
1959
- if (this._inputBuffer.length > 0) {
1960
- const byte = this._inputBuffer.shift();
2883
+ if (this._inputBufferAvailable > 0) {
2884
+ const byte = this._readByte();
1961
2885
  if (byte !== undefined) {
1962
2886
  drained++;
1963
2887
  }
@@ -1974,6 +2898,7 @@ export class ESPLoader extends EventTarget {
1974
2898
  // Final clear of application buffer
1975
2899
  if (!this._parent) {
1976
2900
  this.__inputBuffer = [];
2901
+ this.__inputBufferReadIndex = 0;
1977
2902
  }
1978
2903
  }
1979
2904
 
@@ -1986,6 +2911,7 @@ export class ESPLoader extends EventTarget {
1986
2911
  // Clear application buffer
1987
2912
  if (!this._parent) {
1988
2913
  this.__inputBuffer = [];
2914
+ this.__inputBufferReadIndex = 0;
1989
2915
  }
1990
2916
 
1991
2917
  // Wait for any pending data
@@ -1994,6 +2920,7 @@ export class ESPLoader extends EventTarget {
1994
2920
  // Final clear
1995
2921
  if (!this._parent) {
1996
2922
  this.__inputBuffer = [];
2923
+ this.__inputBufferReadIndex = 0;
1997
2924
  }
1998
2925
 
1999
2926
  this.logger.debug("Serial buffers flushed");
@@ -2029,7 +2956,38 @@ export class ESPLoader extends EventTarget {
2029
2956
  `Reading ${size} bytes from flash at address 0x${addr.toString(16)}...`,
2030
2957
  );
2031
2958
 
2032
- 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
+ }
2033
2991
 
2034
2992
  let allData = new Uint8Array(0);
2035
2993
  let currentAddr = addr;
@@ -2045,6 +3003,7 @@ export class ESPLoader extends EventTarget {
2045
3003
  // Retry loop for this chunk
2046
3004
  while (!chunkSuccess && retryCount <= MAX_RETRIES) {
2047
3005
  let resp = new Uint8Array(0);
3006
+ let lastAckedLength = 0; // Track last acknowledged length
2048
3007
 
2049
3008
  try {
2050
3009
  // Only log on first attempt or retries
@@ -2054,9 +3013,34 @@ export class ESPLoader extends EventTarget {
2054
3013
  );
2055
3014
  }
2056
3015
 
2057
- // Send read flash command for this chunk
2058
- // This must be inside the retry loop so we send a fresh command after errors
2059
- 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
+
2060
3044
  const [res] = await this.checkCommand(ESP_READ_FLASH, pkt);
2061
3045
 
2062
3046
  if (res != 0) {
@@ -2108,10 +3092,22 @@ export class ESPLoader extends EventTarget {
2108
3092
  newResp.set(packetData, resp.length);
2109
3093
  resp = newResp;
2110
3094
 
2111
- // Send acknowledgment
2112
- const ackData = pack("<I", resp.length);
2113
- const slipEncodedAck = slipEncode(ackData);
2114
- 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
+ }
2115
3111
  }
2116
3112
  }
2117
3113
 
@@ -2122,9 +3118,93 @@ export class ESPLoader extends EventTarget {
2122
3118
  allData = newAllData;
2123
3119
 
2124
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
+ }
2125
3173
  } catch (err) {
2126
3174
  retryCount++;
2127
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
+
2128
3208
  // Check if it's a timeout error or SLIP error
2129
3209
  if (err instanceof SlipReadError) {
2130
3210
  if (retryCount <= MAX_RETRIES) {
@@ -2146,12 +3226,13 @@ export class ESPLoader extends EventTarget {
2146
3226
  this.logger.debug(`Buffer drain error: ${drainErr}`);
2147
3227
  }
2148
3228
  } else {
2149
- // All retries exhausted - attempt deep recovery by reconnecting and reloading stub
3229
+ // All retries exhausted - attempt recovery by reloading stub
3230
+ // IMPORTANT: Do NOT close port to keep ESP32 in bootloader mode
2150
3231
  if (!deepRecoveryAttempted) {
2151
3232
  deepRecoveryAttempted = true;
2152
3233
 
2153
3234
  this.logger.log(
2154
- `All retries exhausted at 0x${currentAddr.toString(16)}. Attempting deep recovery (reconnect + reload stub)...`,
3235
+ `All retries exhausted at 0x${currentAddr.toString(16)}. Attempting recovery (close and reopen port)...`,
2155
3236
  );
2156
3237
 
2157
3238
  try {
@@ -2165,15 +3246,15 @@ export class ESPLoader extends EventTarget {
2165
3246
  // Reset retry counter to give it another chance after recovery
2166
3247
  retryCount = 0;
2167
3248
  continue;
2168
- } catch (reconnectErr) {
3249
+ } catch (recoveryErr) {
2169
3250
  throw new Error(
2170
- `Failed to read chunk at 0x${currentAddr.toString(16)} after ${MAX_RETRIES} retries and deep recovery failed: ${reconnectErr}`,
3251
+ `Failed to read chunk at 0x${currentAddr.toString(16)} after ${MAX_RETRIES} retries and recovery failed: ${recoveryErr}`,
2171
3252
  );
2172
3253
  }
2173
3254
  } else {
2174
- // Deep recovery already attempted, give up
3255
+ // Recovery already attempted, give up
2175
3256
  throw new Error(
2176
- `Failed to read chunk at 0x${currentAddr.toString(16)} after ${MAX_RETRIES} retries and deep recovery attempt`,
3257
+ `Failed to read chunk at 0x${currentAddr.toString(16)} after ${MAX_RETRIES} retries and recovery attempt`,
2177
3258
  );
2178
3259
  }
2179
3260
  }
@@ -2197,7 +3278,6 @@ export class ESPLoader extends EventTarget {
2197
3278
  );
2198
3279
  }
2199
3280
 
2200
- this.logger.debug(`Successfully read ${allData.length} bytes from flash`);
2201
3281
  return allData;
2202
3282
  }
2203
3283
  }
@@ -2258,10 +3338,61 @@ class EspStubLoader extends ESPLoader {
2258
3338
  }
2259
3339
 
2260
3340
  /**
2261
- * @name getEraseSize
2262
- * depending on flash chip model the erase may take this long (maybe longer!)
3341
+ * @name eraseFlash
3342
+ * Erase entire flash chip
2263
3343
  */
2264
3344
  async eraseFlash() {
2265
3345
  await this.checkCommand(ESP_ERASE_FLASH, [], 0, CHIP_ERASE_TIMEOUT);
2266
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
+ }
2267
3398
  }