esp32tool 1.6.3 → 1.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -399,8 +399,19 @@ async function cmdEraseFlash(esploader: ESPLoader) {
399
399
  // Use stub for erasing
400
400
  const stub = await esploader.runStub();
401
401
 
402
- // Erase flash
403
- await stub.eraseFlash();
402
+ // Show animated progress while erasing
403
+ const frames = ["|", "/", "-", "\\"];
404
+ let frameIdx = 0;
405
+ const spinner = setInterval(() => {
406
+ process.stdout.write(`\rErasing... ${frames[frameIdx++ % frames.length]}`);
407
+ }, 200);
408
+
409
+ try {
410
+ await stub.eraseFlash();
411
+ } finally {
412
+ clearInterval(spinner);
413
+ process.stdout.write("\r \r");
414
+ }
404
415
 
405
416
  cliLogger.log("Erase complete!");
406
417
  }
package/src/console.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ColoredConsole, coloredConsoleStyles } from "./util/console-color.js";
2
2
  import { LineBreakTransformer } from "./util/line-break-transformer.js";
3
+ import { TimestampTransformer } from "./util/timestamp-transformer.js";
3
4
 
4
5
  export class ESP32ToolConsole {
5
6
  private port: SerialPort;
@@ -8,6 +9,11 @@ export class ESP32ToolConsole {
8
9
  private containerElement: HTMLElement;
9
10
  private allowInput: boolean;
10
11
 
12
+ // Command history buffer
13
+ private commandHistory: string[] = [];
14
+ private historyIndex: number = -1;
15
+ private currentInput: string = "";
16
+
11
17
  constructor(
12
18
  port: SerialPort,
13
19
  containerElement: HTMLElement,
@@ -163,6 +169,17 @@ export class ESP32ToolConsole {
163
169
  ev.preventDefault();
164
170
  ev.stopPropagation();
165
171
  this._sendCommand();
172
+ } else if (ev.key === "ArrowUp") {
173
+ ev.preventDefault();
174
+ this._navigateHistory(1, input);
175
+ } else if (ev.key === "ArrowDown") {
176
+ ev.preventDefault();
177
+ this._navigateHistory(-1, input);
178
+ } else {
179
+ // User is editing — reset history navigation to live input
180
+ if (this.historyIndex !== -1) {
181
+ this.historyIndex = -1;
182
+ }
166
183
  }
167
184
  });
168
185
  }
@@ -218,11 +235,11 @@ export class ESP32ToolConsole {
218
235
  },
219
236
  )
220
237
  .pipeThrough(new TransformStream(new LineBreakTransformer()))
238
+ .pipeThrough(new TransformStream(new TimestampTransformer()))
221
239
  .pipeTo(
222
240
  new WritableStream({
223
241
  write: (chunk) => {
224
- const cleaned = chunk.replace(/\r\n$/, "\n");
225
- this.console!.addLine(cleaned);
242
+ this.console!.addLine(chunk);
226
243
  },
227
244
  }),
228
245
  );
@@ -241,11 +258,49 @@ export class ESP32ToolConsole {
241
258
  }
242
259
  }
243
260
 
261
+ private _navigateHistory(direction: 1 | -1, input: HTMLInputElement) {
262
+ if (this.commandHistory.length === 0) return;
263
+
264
+ // Save current unsent input before navigating away
265
+ if (this.historyIndex === -1) {
266
+ this.currentInput = input.value;
267
+ }
268
+
269
+ const nextIndex = this.historyIndex + direction;
270
+
271
+ if (nextIndex < 0) {
272
+ // Back to unsent draft
273
+ this.historyIndex = -1;
274
+ input.value = this.currentInput;
275
+ } else if (nextIndex < this.commandHistory.length) {
276
+ this.historyIndex = nextIndex;
277
+ input.value = this.commandHistory[this.historyIndex];
278
+ }
279
+
280
+ // Move cursor to end
281
+ const len = input.value.length;
282
+ input.setSelectionRange(len, len);
283
+ }
284
+
244
285
  private async _sendCommand() {
245
286
  const input = this.containerElement.querySelector<HTMLInputElement>(
246
287
  ".esp32tool-console-input",
247
288
  )!;
248
289
  const command = input.value;
290
+
291
+ if (command.trim() !== "") {
292
+ // Avoid consecutive duplicates, cap at 100
293
+ if (this.commandHistory[0] !== command) {
294
+ this.commandHistory.unshift(command);
295
+ if (this.commandHistory.length > 100) {
296
+ this.commandHistory.pop();
297
+ }
298
+ }
299
+ }
300
+ // Reset history navigation state
301
+ this.historyIndex = -1;
302
+ this.currentInput = "";
303
+
249
304
  if (!this.port.writable) {
250
305
  this.console!.addLine("Terminal disconnected: port not writable");
251
306
  return;
package/src/esp_loader.ts CHANGED
@@ -4335,6 +4335,8 @@ export class ESPLoader extends EventTarget {
4335
4335
  // Flush serial buffers before flash read operation
4336
4336
  await this.flushSerialBuffers();
4337
4337
 
4338
+ const readStartTime = Date.now();
4339
+
4338
4340
  this.logger.log(
4339
4341
  `Reading ${size} bytes from flash at address 0x${addr.toString(16)}...`,
4340
4342
  );
@@ -4442,6 +4444,8 @@ export class ESPLoader extends EventTarget {
4442
4444
  maxInFlight,
4443
4445
  );
4444
4446
 
4447
+ const chunkStartTime = Date.now();
4448
+
4445
4449
  const [res] = await this.checkCommand(ESP_READ_FLASH, pkt);
4446
4450
 
4447
4451
  if (res != 0) {
@@ -4520,6 +4524,16 @@ export class ESPLoader extends EventTarget {
4520
4524
 
4521
4525
  chunkSuccess = true;
4522
4526
 
4527
+ const chunkDuration = Date.now() - chunkStartTime;
4528
+ const speedKBs = (
4529
+ resp.length /
4530
+ 1024 /
4531
+ (chunkDuration / 1000)
4532
+ ).toFixed(1);
4533
+ this.logger.debug(
4534
+ `Chunk read took ${chunkDuration} ms (${resp.length} bytes, ${speedKBs} KB/s)`,
4535
+ );
4536
+
4523
4537
  // ADAPTIVE SPEED ADJUSTMENT: Only for CDC devices
4524
4538
  // Non-CDC devices (CH340, CP2102) stay at fixed blockSize=31, maxInFlight=31
4525
4539
  if (this.isWebUSB() && this._isCDCDevice && retryCount === 0) {
@@ -4666,6 +4680,16 @@ export class ESPLoader extends EventTarget {
4666
4680
  );
4667
4681
  }
4668
4682
 
4683
+ const totalDuration = Date.now() - readStartTime;
4684
+ const totalSpeedKBs = (
4685
+ allData.length /
4686
+ 1024 /
4687
+ (totalDuration / 1000)
4688
+ ).toFixed(1);
4689
+ this.logger.log(
4690
+ `Read complete: ${allData.length} bytes in ${(totalDuration / 1000).toFixed(1)} s (${totalSpeedKBs} KB/s)`,
4691
+ );
4692
+
4669
4693
  return allData;
4670
4694
  }
4671
4695
  }
@@ -6,7 +6,10 @@ interface ConsoleState {
6
6
  foregroundColor: string | null;
7
7
  backgroundColor: string | null;
8
8
  carriageReturn: boolean;
9
+ lines: string[];
9
10
  secret: boolean;
11
+ blink: boolean;
12
+ rapidBlink: boolean;
10
13
  }
11
14
 
12
15
  export class ColoredConsole {
@@ -18,39 +21,29 @@ export class ColoredConsole {
18
21
  foregroundColor: null,
19
22
  backgroundColor: null,
20
23
  carriageReturn: false,
24
+ lines: [],
21
25
  secret: false,
26
+ blink: false,
27
+ rapidBlink: false,
22
28
  };
23
29
 
24
30
  constructor(public targetElement: HTMLElement) {}
25
31
 
26
32
  logs(): string {
33
+ if (this.state.lines.length > 0) {
34
+ this.processLines();
35
+ }
27
36
  return this.targetElement.innerText;
28
37
  }
29
38
 
30
- addLine(line: string) {
39
+ processLine(line: string): Element {
31
40
  // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences
32
41
  // eslint-disable-next-line no-control-regex
33
42
  const re = /(?:\x1B|\\x1B)(?:\[(.*?)[@-~]|\].*?(?:\x07|\x1B\\))/g;
34
43
  let i = 0;
35
44
 
36
- if (this.state.carriageReturn) {
37
- if (line !== "\n") {
38
- // don't remove if \r\n
39
- if (this.targetElement.lastChild) {
40
- this.targetElement.removeChild(this.targetElement.lastChild);
41
- }
42
- }
43
- this.state.carriageReturn = false;
44
- }
45
-
46
- const hasBareCR = line.endsWith("\r") && !line.endsWith("\r\n");
47
- if (hasBareCR) {
48
- this.state.carriageReturn = true;
49
- }
50
-
51
45
  const lineSpan = document.createElement("span");
52
46
  lineSpan.classList.add("line");
53
- this.targetElement.appendChild(lineSpan);
54
47
 
55
48
  const addSpan = (content: string) => {
56
49
  if (content === "") return;
@@ -61,6 +54,8 @@ export class ColoredConsole {
61
54
  if (this.state.underline) span.classList.add("log-underline");
62
55
  if (this.state.strikethrough) span.classList.add("log-strikethrough");
63
56
  if (this.state.secret) span.classList.add("log-secret");
57
+ if (this.state.blink) span.classList.add("log-blink");
58
+ if (this.state.rapidBlink) span.classList.add("log-rapid-blink");
64
59
  if (this.state.foregroundColor !== null)
65
60
  span.classList.add(`log-fg-${this.state.foregroundColor}`);
66
61
  if (this.state.backgroundColor !== null)
@@ -97,6 +92,8 @@ export class ColoredConsole {
97
92
  this.state.foregroundColor = null;
98
93
  this.state.backgroundColor = null;
99
94
  this.state.secret = false;
95
+ this.state.blink = false;
96
+ this.state.rapidBlink = false;
100
97
  break;
101
98
  case 1:
102
99
  this.state.bold = true;
@@ -108,10 +105,15 @@ export class ColoredConsole {
108
105
  this.state.underline = true;
109
106
  break;
110
107
  case 5:
111
- this.state.secret = true;
108
+ this.state.blink = true;
109
+ this.state.rapidBlink = false;
112
110
  break;
113
111
  case 6:
114
- this.state.secret = false;
112
+ this.state.rapidBlink = true;
113
+ this.state.blink = false;
114
+ break;
115
+ case 8:
116
+ this.state.secret = true;
115
117
  break;
116
118
  case 9:
117
119
  this.state.strikethrough = true;
@@ -125,6 +127,13 @@ export class ColoredConsole {
125
127
  case 24:
126
128
  this.state.underline = false;
127
129
  break;
130
+ case 25:
131
+ this.state.blink = false;
132
+ this.state.rapidBlink = false;
133
+ break;
134
+ case 28:
135
+ this.state.secret = false;
136
+ break;
128
137
  case 29:
129
138
  this.state.strikethrough = false;
130
139
  break;
@@ -155,6 +164,9 @@ export class ColoredConsole {
155
164
  case 39:
156
165
  this.state.foregroundColor = null;
157
166
  break;
167
+ case 40:
168
+ this.state.backgroundColor = "black";
169
+ break;
158
170
  case 41:
159
171
  this.state.backgroundColor = "red";
160
172
  break;
@@ -176,26 +188,69 @@ export class ColoredConsole {
176
188
  case 47:
177
189
  this.state.backgroundColor = "white";
178
190
  break;
179
- case 40:
180
- this.state.backgroundColor = "black";
181
- break;
182
191
  case 49:
183
192
  this.state.backgroundColor = null;
184
193
  break;
185
194
  }
186
195
  }
187
196
  }
197
+ addSpan(line.substring(i));
198
+ return lineSpan;
199
+ }
200
+
201
+ processLines() {
188
202
  const atBottom =
189
203
  this.targetElement.scrollTop >
190
204
  this.targetElement.scrollHeight - this.targetElement.offsetHeight - 50;
205
+ const prevCarriageReturn = this.state.carriageReturn;
206
+ const fragment = document.createDocumentFragment();
191
207
 
192
- addSpan(line.substring(i));
208
+ if (this.state.lines.length === 0) {
209
+ return;
210
+ }
211
+
212
+ for (const line of this.state.lines) {
213
+ // A lone \r is a pure carriage-return signal — update state but don't
214
+ // create a DOM node for it (it has no renderable content).
215
+ if (line === "\r") {
216
+ this.state.carriageReturn = true;
217
+ continue;
218
+ }
219
+ if (this.state.carriageReturn && line !== "\n") {
220
+ if (fragment.childElementCount) {
221
+ fragment.removeChild(fragment.lastChild!);
222
+ }
223
+ }
224
+ const hadCarriageReturn = line.endsWith("\r");
225
+ fragment.appendChild(this.processLine(line.replace(/\r/g, "")));
226
+ this.state.carriageReturn = hadCarriageReturn;
227
+ }
228
+
229
+ if (
230
+ prevCarriageReturn &&
231
+ fragment.childElementCount > 0 &&
232
+ this.targetElement.lastChild
233
+ ) {
234
+ this.targetElement.replaceChild(fragment, this.targetElement.lastChild!);
235
+ } else {
236
+ this.targetElement.appendChild(fragment);
237
+ }
238
+
239
+ this.state.lines = [];
193
240
 
194
241
  // Keep scroll at bottom
195
242
  if (atBottom) {
196
243
  this.targetElement.scrollTop = this.targetElement.scrollHeight;
197
244
  }
198
245
  }
246
+
247
+ addLine(line: string) {
248
+ // Processing of lines is deferred for performance reasons
249
+ if (this.state.lines.length === 0) {
250
+ setTimeout(() => this.processLines(), 0);
251
+ }
252
+ this.state.lines.push(line);
253
+ }
199
254
  }
200
255
 
201
256
  export const coloredConsoleStyles = `
@@ -229,6 +284,17 @@ export const coloredConsoleStyles = `
229
284
  .log-underline.log-strikethrough {
230
285
  text-decoration: underline line-through;
231
286
  }
287
+ .log-blink {
288
+ animation: blink 1s step-end infinite;
289
+ }
290
+ .log-rapid-blink {
291
+ animation: blink 0.4s step-end infinite;
292
+ }
293
+ @keyframes blink {
294
+ 50% {
295
+ opacity: 0;
296
+ }
297
+ }
232
298
  .log-secret {
233
299
  -webkit-user-select: none;
234
300
  -moz-user-select: none;
@@ -7,10 +7,24 @@ export class LineBreakTransformer implements Transformer<string, string> {
7
7
  ) {
8
8
  // Append new chunks to existing chunks.
9
9
  this.chunks += chunk;
10
- // For each line breaks in chunks, send the parsed lines out.
11
- const lines = this.chunks.split("\r\n");
12
- this.chunks = lines.pop()!;
13
- lines.forEach((line) => controller.enqueue(line + "\r\n"));
10
+ // Split on \r\n, lone \r, or lone \n — capturing the separator so we can
11
+ // distinguish a lone \r (overwrite intent) from a normal newline.
12
+ const re = /\r\n|\r|\n/g;
13
+ let lastIndex = 0;
14
+ let match: RegExpExecArray | null;
15
+ while ((match = re.exec(this.chunks)) !== null) {
16
+ // If this is a lone \r at the very end of the buffer, leave it so it can
17
+ // be combined with a possible following \n in the next chunk.
18
+ if (match[0] === "\r" && match.index === this.chunks.length - 1) {
19
+ break;
20
+ }
21
+ const line = this.chunks.substring(lastIndex, match.index);
22
+ // Emit with \r suffix only for lone \r (overwrite), \n for everything else.
23
+ const suffix = match[0] === "\r" ? "\r" : "\n";
24
+ controller.enqueue(line + suffix);
25
+ lastIndex = re.lastIndex;
26
+ }
27
+ this.chunks = this.chunks.substring(lastIndex);
14
28
  }
15
29
 
16
30
  flush(controller: TransformStreamDefaultController<string>) {
@@ -0,0 +1,47 @@
1
+ // Matches lines that already carry a wall-clock or tick timestamp so we don't
2
+ // add a redundant one. Intentionally does NOT match bare log-level prefixes
3
+ // like ESPHome's [I][tag:line]: — those have no time information.
4
+ //
5
+ // Covered formats:
6
+ // (123456) FreeRTOS ms-tick e.g. "(12345) "
7
+ // [HH:MM:SS] wall-clock bracket
8
+ // [HH:MM:SS.mmm] wall-clock bracket with millis
9
+ // I (1234) tag: ESP-IDF log level + tick e.g. "I (1234) wifi: ..."
10
+ // HH:MM:SS.mmm plain wall-clock
11
+ const DEVICE_TIMESTAMP_RE =
12
+ /^\s*(?:\(\d+\)\s|\[\d{2}:\d{2}:\d{2}(?:\.\d+)?\]|[DIWEACV] \(\d+\) \w|(?:\d{2}:){2}\d{2}\.\d)/;
13
+
14
+ export class TimestampTransformer implements Transformer<string, string> {
15
+ private deviceHasTimestamps = false;
16
+
17
+ transform(
18
+ chunk: string,
19
+ controller: TransformStreamDefaultController<string>,
20
+ ) {
21
+ // Pass through pure newline / empty sentinel unchanged so that
22
+ // carriage-return overwrite logic in console-color.ts still works.
23
+ if (chunk === "" || chunk === "\n" || chunk === "\r") {
24
+ controller.enqueue(chunk);
25
+ return;
26
+ }
27
+
28
+ if (!this.deviceHasTimestamps && DEVICE_TIMESTAMP_RE.test(chunk)) {
29
+ this.deviceHasTimestamps = true;
30
+ }
31
+
32
+ if (this.deviceHasTimestamps) {
33
+ controller.enqueue(chunk);
34
+ return;
35
+ }
36
+
37
+ const date = new Date();
38
+ const h = date.getHours().toString().padStart(2, "0");
39
+ const m = date.getMinutes().toString().padStart(2, "0");
40
+ const s = date.getSeconds().toString().padStart(2, "0");
41
+ controller.enqueue(`[${h}:${m}:${s}] ${chunk}`);
42
+ }
43
+
44
+ reset() {
45
+ this.deviceHasTimestamps = false;
46
+ }
47
+ }
package/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Service Worker for ESP32Tool PWA
2
- const CACHE_NAME = 'esp32tool-v1.6.3';
2
+ const CACHE_NAME = 'esp32tool-v1.6.5';
3
3
  const RUNTIME_CACHE = 'esp32tool-runtime';
4
4
 
5
5
  // Core files to cache on install (relative paths work for any deployment path)
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "js/util",
5
+ "declaration": false,
6
+ "removeComments": false
7
+ },
8
+ "include": ["src/util/**/*.ts"]
9
+ }