From ef8ada9bc512a703750f089fc61aeccdfec6246c Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Mon, 23 Mar 2026 00:05:54 +0100 Subject: [PATCH 1/3] Initial improvements --- src/routes/flashtool/+page.svelte | 377 +++++++++++++-------- src/routes/flashtool/FlashManager.ts | 139 ++++---- src/routes/flashtool/SerialTerminal.svelte | 151 +++++++++ src/routes/flashtool/ansi.ts | 113 ++++++ 4 files changed, 589 insertions(+), 191 deletions(-) create mode 100644 src/routes/flashtool/SerialTerminal.svelte create mode 100644 src/routes/flashtool/ansi.ts diff --git a/src/routes/flashtool/+page.svelte b/src/routes/flashtool/+page.svelte index 8e00ba99..7d65bd4f 100644 --- a/src/routes/flashtool/+page.svelte +++ b/src/routes/flashtool/+page.svelte @@ -1,52 +1,60 @@ -{#snippet mainContent()} - - - {#if manager} -

Select Channel

- - -

Select Board

- - -
- -
- -

- Flash tool will erase all data on the device before flashing, in the process clearing any - existing configs -

-
-
+ (showHelpDialog = false)} /> - {#if version && board} - +
+ +
+

Flash Tool

+ {#if isSerialSupported} + {/if} - {:else if port && !connectFailed} -
- Connecting... - - -
- {/if} +
- {#if port && connectFailed} -
- Device connection failed - - There was an issue connecting to your device, please try the following: - -
    -
  1. Install the drivers for your device if you haven't already, using the button above
  2. -
  3. Unplug and replug your device
  4. -
  5. Use a different USB port
  6. -
  7. Use a different USB cable
  8. -
  9. - Contact support if the issue persists: - -
  10. -
-
+ +
+ {#if isSerialSupported} +
+ {#each STEPS as step, i (i)} + {@const isCompleted = i < currentStep} + {@const isCurrent = i === activeStep} + {@const isAccessible = i <= currentStep} + {@const isLast = i === STEPS.length - 1} + +
+ +
+ + + + {#if !isLast} +
+ {/if} +
+ + +
+ + + + + {#if i === 0} + {#if isCurrent} +
+ + + {#if port && !manager && !connectFailed} +
+ Connecting... + +
+ {/if} + + {#if port && connectFailed} +
+ Device connection failed + + There was an issue connecting to your device, please try the following: + +
    +
  1. Install the drivers for your device
  2. +
  3. Unplug and replug your device
  4. +
  5. Use a different USB port or cable
  6. +
  7. + Contact support: + +
  8. +
+
+ {/if} +
+ {:else if isCompleted} +

Device connected

+ {/if} + {/if} + + + {#if i === 1} + {#if isCurrent} +
+ + {#if version} + + {/if} +
+ {:else if isCompleted} +

+ {channel} · {version} +

+ {/if} + {/if} + + + {#if i === 2} + {#if isCurrent} +
+ + +
+ +
+ +

+ Clears all data on the device including existing configs +

+
+
+
+ {:else if isCompleted} +

+ {board}{eraseBeforeFlash ? ' (erase all)' : ''} +

+ {/if} + {/if} + + + {#if i === 3} + {#if isCurrent && version && board && manager} + + {/if} + {/if} +
+
+ {/each} +
+ {:else if browser} + {@render unsupportedBrowser()} + {:else} +

Loading...

+ {/if} +
+ + + {#if port && isSerialSupported} + (terminalLines = [])} + onReset={handleReset} + onSendCommand={handleSendCommand} + /> {/if} -{/snippet} +
{#snippet unsupportedBrowser()}

Your browser does not support this feature.

@@ -189,57 +352,3 @@ {/if} {/snippet} - - (showHelpDialog = false)} /> - - - - Flash Tool - {#if isSerialSupported} -
- - -
- {/if} -
- - {#if isSerialSupported} - {@render mainContent()} - {:else if browser} - {@render unsupportedBrowser()} - {:else} - Loading... - {/if} - -
- - terminalOpen, (o) => (terminalOpen = o)}> - - - - Console -
- - - -
-
-
-
{terminalText}
-
- - {#snippet after()} - - {/snippet} - -
-
diff --git a/src/routes/flashtool/FlashManager.ts b/src/routes/flashtool/FlashManager.ts index e3cfef4e..9aacb3ae 100644 --- a/src/routes/flashtool/FlashManager.ts +++ b/src/routes/flashtool/FlashManager.ts @@ -131,28 +131,44 @@ export default class FlashManager { */ private terminal: IEspLoaderTerminal; /** - * Chip: During connect, the chip is read from the ESPLoader. + * Chip: Detected during bootloader setup. null until first ensureBootloader() call. */ - private chip: string; + private chip: string | null; - private constructor(loader: ESPLoader, terminal: IEspLoaderTerminal) { - this.serialPort = loader.transport.device; + private constructor(serialPort: SerialPort, terminal: IEspLoaderTerminal, loader?: ESPLoader) { + this.serialPort = serialPort; this.serialPortReader = null; this.serialPortWriter = null; - this.loader = loader; + this.loader = loader ?? null; this.terminal = terminal; - this.chip = loader.chip.CHIP_NAME; + this.chip = loader?.chip.CHIP_NAME ?? null; } - static async Connect(serialPort: SerialPort, terminal: IEspLoaderTerminal) { + /** + * Connect in bootloader mode (legacy). Enters ESPLoader immediately. + */ + static async ConnectBootloader(serialPort: SerialPort, terminal: IEspLoaderTerminal) { const espLoader = await setupESPLoader(serialPort, terminal); if (espLoader != null) { - return new FlashManager(espLoader, terminal); + return new FlashManager(espLoader.transport.device, terminal, espLoader); } else { return null; } } + /** + * Connect in application mode. Opens the port, resets the device into app mode, + * and starts reading serial output immediately. Bootloader is entered lazily when flash is triggered. + */ + static async ConnectApplication(serialPort: SerialPort, terminal: IEspLoaderTerminal) { + const port = await setupApplication(serialPort); + if (!port) return null; + + const manager = new FlashManager(port, terminal); + manager._startApplicationReadLoop(); + return manager; + } + get SerialPort() { return this.serialPort; } @@ -161,6 +177,12 @@ export default class FlashManager { return this.chip; } + get mode(): 'application' | 'bootloader' | 'disconnected' { + if (!this.serialPort) return 'disconnected'; + if (this.loader) return 'bootloader'; + return 'application'; + } + /** * Assumes the FlashManager is connected. * To work around esptool.js issues (namely, any timeout whatsoever corrupts newRead and probably everything else too), some operations have to 'reboot' the transport. @@ -219,66 +241,69 @@ export default class FlashManager { } } - async ensureApplication(forceReset?: boolean) { - if (!this.serialPort) return false; - if (!this.loader && !forceReset) return true; - - const serialPort = await setupApplication(await this._cycleTransport()); - this.serialPort = serialPort; - - if (serialPort) { - const serialPortReader = serialPort!.readable!.getReader(); - const serialPortWriter = serialPort!.writable!.getWriter(); - this.serialPortReader = serialPortReader; - this.serialPortWriter = serialPortWriter; - // connect application to terminal - (async () => { - try { - let lineBuffer: Uint8Array | null = null; // Buffer to hold data between chunks - - while (true) { - // since we're using Transport APIs, and since they have no "no timeout" option, get as close as possible - const { done, value } = await serialPortReader.read(); - if (done) break; // Stream ended - exit the loop - if (!value) { - await sleep(1); // No data received, wait a bit - continue; // Skip to the next iteration - } + /** + * Starts an async read loop that reads from the serial port and writes to the terminal. + * Assumes the port is already open with reader/writer available. + */ + private _startApplicationReadLoop() { + if (!this.serialPort) return; - let start = 0; // Where to start reading from the value + const serialPortReader = this.serialPort.readable!.getReader(); + const serialPortWriter = this.serialPort.writable!.getWriter(); + this.serialPortReader = serialPortReader; + this.serialPortWriter = serialPortWriter; - // Process each byte in the received chunk - for (let i = 0; i < value.length; i++) { - const byte = value[i]; + (async () => { + try { + let lineBuffer: Uint8Array | null = null; + + while (true) { + const { done, value } = await serialPortReader.read(); + if (done) break; + if (!value) { + await sleep(1); + continue; + } - // Skip until we encounter a line terminator (LF or CR) - if (byte !== 10 && byte !== 13) continue; + let start = 0; - // Copy all data from rstart to current index (i) into the buffer - if (i > start) { - lineBuffer = appendBuffer(lineBuffer, value.subarray(start, i)); - } + for (let i = 0; i < value.length; i++) { + const byte = value[i]; - // Line Feed (\n): flush buffer as a complete line - if (byte === 10) { - this.terminal.writeLine(lineBuffer?.length ? DecodeString(lineBuffer) : ''); - lineBuffer = null; // Reset buffer after flushing - } + if (byte !== 10 && byte !== 13) continue; - // Set start to the next byte after the line terminator - start = i + 1; + if (i > start) { + lineBuffer = appendBuffer(lineBuffer, value.subarray(start, i)); } - // Push any remaining data in the buffer - if (start < value.length) { - lineBuffer = appendBuffer(lineBuffer, value.subarray(start)); + if (byte === 10) { + this.terminal.writeLine(lineBuffer?.length ? DecodeString(lineBuffer) : ''); + lineBuffer = null; } + + start = i + 1; + } + + if (start < value.length) { + lineBuffer = appendBuffer(lineBuffer, value.subarray(start)); } - } catch (e) { - console.log(e); - this.terminal.writeLine(`firmware disconnected: ${e}`); } - })(); + } catch (e) { + console.log(e); + this.terminal.writeLine(`firmware disconnected: ${e}`); + } + })(); + } + + async ensureApplication(forceReset?: boolean) { + if (!this.serialPort) return false; + if (!this.loader && !forceReset) return true; + + const serialPort = await setupApplication(await this._cycleTransport()); + this.serialPort = serialPort; + + if (serialPort) { + this._startApplicationReadLoop(); } } diff --git a/src/routes/flashtool/SerialTerminal.svelte b/src/routes/flashtool/SerialTerminal.svelte new file mode 100644 index 00000000..5fa96364 --- /dev/null +++ b/src/routes/flashtool/SerialTerminal.svelte @@ -0,0 +1,151 @@ + + +
+ +
+ Console +
+ + +
+
+ + +
+ {#each visibleLines as line, i (i)} +
+ {formatTime(line.timestamp)} + + {#each line.segments as seg} + {#if Object.keys(seg.style).length > 0} + `${k}:${v}`) + .join(';')}>{seg.text} + {:else} + {seg.text} + {/if} + {/each} + +
+ {/each} +
+ + +
+ $ + + +
+
diff --git a/src/routes/flashtool/ansi.ts b/src/routes/flashtool/ansi.ts new file mode 100644 index 00000000..10d8d7be --- /dev/null +++ b/src/routes/flashtool/ansi.ts @@ -0,0 +1,113 @@ +/** + * Lightweight ANSI escape sequence parser for ESP-IDF serial output. + * Converts ANSI SGR codes to inline CSS styles for rendering in the terminal. + */ + +export interface AnsiSegment { + text: string; + style: Record; +} + +const ANSI_COLORS: Record = { + 30: '#000000', // black + 31: '#ff0000', // red + 32: '#00ff00', // green + 33: '#ffff00', // yellow + 34: '#0000ff', // blue + 35: '#ff00ff', // magenta + 36: '#00ffff', // cyan + 37: '#ffffff', // white + 90: '#808080', // bright black + 91: '#ff6b6b', // bright red + 92: '#51cf66', // bright green + 93: '#ffd93d', // bright yellow + 94: '#74c0fc', // bright blue + 95: '#ff8ed4', // bright magenta + 96: '#35d9d2', // bright cyan + 97: '#ffffff', // bright white +}; + +const ANSI_BG_COLORS: Record = { + 40: '#000000', + 41: '#ff0000', + 42: '#00ff00', + 43: '#ffff00', + 44: '#0000ff', + 45: '#ff00ff', + 46: '#00ffff', + 47: '#ffffff', + 100: '#808080', + 101: '#ff6b6b', + 102: '#51cf66', + 103: '#ffd93d', + 104: '#74c0fc', + 105: '#ff8ed4', + 106: '#35d9d2', + 107: '#ffffff', +}; + +const ESC = '\x1b'; +const ANSI_REGEX = new RegExp(`${ESC}\\[([0-9;]*?)([a-zA-Z])`, 'g'); + +/** + * Parse ANSI escape sequences in text and return styled segments. + */ +export function parseAnsi(text: string): AnsiSegment[] { + if (!text) return [{ text: '', style: {} }]; + + const segments: AnsiSegment[] = []; + let lastIndex = 0; + let currentStyle: Record = {}; + + let match: RegExpExecArray | null; + ANSI_REGEX.lastIndex = 0; + + while ((match = ANSI_REGEX.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ text: text.substring(lastIndex, match.index), style: { ...currentStyle } }); + } + + const codes = match[1] ? match[1].split(';').map(Number) : [0]; + const command = match[2]; + + if (command === 'm') { + for (const code of codes) { + if (code === 0) { + currentStyle = {}; + } else if (code === 1) { + currentStyle['font-weight'] = 'bold'; + } else if (code === 3) { + currentStyle['font-style'] = 'italic'; + } else if (code === 4) { + currentStyle['text-decoration'] = 'underline'; + } else if (code === 22) { + delete currentStyle['font-weight']; + } else if (code === 23) { + delete currentStyle['font-style']; + } else if (code === 24) { + delete currentStyle['text-decoration']; + } else if (code in ANSI_COLORS) { + currentStyle['color'] = ANSI_COLORS[code]; + } else if (code in ANSI_BG_COLORS) { + currentStyle['background-color'] = ANSI_BG_COLORS[code]; + } + } + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + segments.push({ text: text.substring(lastIndex), style: { ...currentStyle } }); + } + + return segments.length > 0 ? segments : [{ text, style: {} }]; +} + +/** + * Strip all ANSI escape sequences from text. + */ +export function stripAnsi(text: string): string { + ANSI_REGEX.lastIndex = 0; + return text.replace(ANSI_REGEX, ''); +} From 195fed098435e214a62b596e4782dc44ce4fd903 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 1 Apr 2026 11:02:26 +0200 Subject: [PATCH 2/3] Address PR comments and add more colors --- src/routes/flashtool/+page.svelte | 54 ++++-- src/routes/flashtool/FlashManager.ts | 17 +- src/routes/flashtool/SerialTerminal.svelte | 47 ++++- src/routes/flashtool/ansi.test.ts | 214 +++++++++++++++++++++ src/routes/flashtool/ansi.ts | 47 +++++ 5 files changed, 362 insertions(+), 17 deletions(-) create mode 100644 src/routes/flashtool/ansi.test.ts diff --git a/src/routes/flashtool/+page.svelte b/src/routes/flashtool/+page.svelte index 7f63b189..7e142526 100644 --- a/src/routes/flashtool/+page.svelte +++ b/src/routes/flashtool/+page.svelte @@ -13,7 +13,7 @@ import { Progress } from '$lib/components/ui/progress'; import { useSerial } from '$lib/utils/serial-context.svelte'; import { getBrowserName, isSerialSupported } from '$lib/utils/compatibility'; - import { parseAnsi } from './ansi'; + import { parseAnsi, parseLogLine } from './ansi'; import FirmwareBoardSelector from './FirmwareBoardSelector.svelte'; import FirmwareFlasher from './FirmwareFlasher.svelte'; import { useFlashManager } from './flash-context.svelte'; @@ -25,28 +25,49 @@ let port = $state(null); + const MAX_LINES = 5000; + let lineIdCounter = 0; let terminalLines = $state([]); + function makeTerminalLine(text: string, timestamp?: Date): TerminalLine { + const parsed = parseLogLine(text); + return { + id: lineIdCounter++, + text, + timestamp: timestamp ?? new Date(), + segments: parseAnsi(text), + logLevel: parsed?.logLevel ?? null, + deviceUptime: parsed?.deviceUptime ?? null, + logTag: parsed?.tag ?? null, + }; + } + const terminal = { clean: () => { terminalLines = []; }, writeLine: (data: string) => { - terminalLines = [ - ...terminalLines, - { text: data, timestamp: new Date(), segments: parseAnsi(data) }, - ]; + const newLines = [...terminalLines, makeTerminalLine(data)]; + terminalLines = newLines.length > MAX_LINES ? newLines.slice(-MAX_LINES) : newLines; }, write: (data: string) => { if (terminalLines.length > 0) { const last = terminalLines[terminalLines.length - 1]; const newText = last.text + data; + const parsed = parseLogLine(newText); terminalLines = [ ...terminalLines.slice(0, -1), - { text: newText, timestamp: last.timestamp, segments: parseAnsi(newText) }, + { + ...last, + text: newText, + segments: parseAnsi(newText), + logLevel: parsed?.logLevel ?? null, + deviceUptime: parsed?.deviceUptime ?? null, + logTag: parsed?.tag ?? null, + }, ]; } else { - terminalLines = [{ text: data, timestamp: new Date(), segments: parseAnsi(data) }]; + terminalLines = [makeTerminalLine(data)]; } }, }; @@ -65,15 +86,22 @@ let channel = $state('stable'); let version = $state(null); - // Tracks which channel the user explicitly confirmed. Changing channel invalidates it. + // Tracks which channel+version the user explicitly confirmed. Changing either invalidates it. let confirmedChannel = $state(null); + let confirmedVersion = $state(null); let board = $state(null); let eraseBeforeFlash = $state(false); // Stepper: derive which step we're on based on state - // Channel step requires explicit confirmation (confirmedChannel must match current channel) + // Channel step requires explicit confirmation (both channel and version must match confirmed values) let currentStep = $derived( - !flash.manager ? 0 : !(version && confirmedChannel === channel) ? 1 : !board ? 2 : 3 + !flash.manager + ? 0 + : !(version && confirmedChannel === channel && confirmedVersion === version) + ? 1 + : !board + ? 2 + : 3 ); // Allow users to view earlier steps @@ -166,6 +194,7 @@ : 'border-muted-foreground/30 text-muted-foreground/50 border-2'}" disabled={!isAccessible || flash.isFlashing} onclick={() => goToStep(i)} + aria-label="Go to step {i + 1}: {step.title}" > {#if isCompleted} @@ -245,7 +274,10 @@ /> {#if version}