diff --git a/src/routes/flashtool/+page.svelte b/src/routes/flashtool/+page.svelte index b4b88f62..7e142526 100644 --- a/src/routes/flashtool/+page.svelte +++ b/src/routes/flashtool/+page.svelte @@ -1,45 +1,74 @@ -{#snippet mainContent()} - - - {#if flash.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 && !flash.connectFailed} -
- Connecting... - - -
- {/if} +
- {#if port && flash.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 && !flash.manager && !flash.connectFailed} +
+ Connecting... + +
+ {/if} + + {#if port && flash.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 && flash.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.

@@ -185,57 +384,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 b2d6e3e2..e1d795b1 100644 --- a/src/routes/flashtool/FlashManager.ts +++ b/src/routes/flashtool/FlashManager.ts @@ -128,28 +128,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; } @@ -158,6 +174,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. @@ -216,67 +238,85 @@ 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}`); + } finally { + try { + serialPortReader.releaseLock(); + } catch { + /* ignore */ + } + try { + serialPortWriter.releaseLock(); + } catch { + /* ignore */ + } + if (this.serialPortReader === serialPortReader) this.serialPortReader = null; + if (this.serialPortWriter === serialPortWriter) this.serialPortWriter = null; + } + })(); + } + + async ensureApplication(forceReset?: boolean): Promise { + 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(); + return true; } + return false; } async disconnect() { diff --git a/src/routes/flashtool/SerialTerminal.svelte b/src/routes/flashtool/SerialTerminal.svelte new file mode 100644 index 00000000..4ac0b6dc --- /dev/null +++ b/src/routes/flashtool/SerialTerminal.svelte @@ -0,0 +1,191 @@ + + +
+ +
+ Console +
+ + + +
+
+ + +
+ {#each visibleLines as line (line.id)} +
+ {line.deviceUptime != null + ? formatUptime(line.deviceUptime) + : formatTime(line.timestamp)} + + {#each line.segments as seg, j (j)} + {#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.test.ts b/src/routes/flashtool/ansi.test.ts new file mode 100644 index 00000000..ce7a831f --- /dev/null +++ b/src/routes/flashtool/ansi.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; +import { parseAnsi, parseLogLine, stripAnsi } from './ansi'; + +describe('parseLogLine', () => { + describe('OpenShock format: [uptimeMs][LEVEL][tag]', () => { + it('parses error line', () => { + const result = parseLogLine('[56984][E][Config.cpp:185] SaveFromJSON():'); + expect(result).toEqual({ logLevel: 'E', deviceUptime: 56984, tag: 'Config.cpp:185' }); + }); + + it('parses warning line', () => { + const result = parseLogLine('[1234][W][WiFi.cpp:42] connect(): timeout'); + expect(result).toEqual({ logLevel: 'W', deviceUptime: 1234, tag: 'WiFi.cpp:42' }); + }); + + it('parses info line', () => { + const result = parseLogLine('[500][I][Main.cpp:10] setup(): ready'); + expect(result).toEqual({ logLevel: 'I', deviceUptime: 500, tag: 'Main.cpp:10' }); + }); + + it('parses debug line', () => { + const result = parseLogLine('[99999][D][Serial.cpp:77] read(): got 12 bytes'); + expect(result).toEqual({ logLevel: 'D', deviceUptime: 99999, tag: 'Serial.cpp:77' }); + }); + + it('parses verbose line', () => { + const result = parseLogLine('[0][V][Boot.cpp:1] init():'); + expect(result).toEqual({ logLevel: 'V', deviceUptime: 0, tag: 'Boot.cpp:1' }); + }); + + it('parses with leading ANSI escape', () => { + const result = parseLogLine('\x1b[0;31m[56984][E][Config.cpp:185] SaveFromJSON():'); + expect(result).toEqual({ logLevel: 'E', deviceUptime: 56984, tag: 'Config.cpp:185' }); + }); + + it('parses with multiple leading ANSI escapes', () => { + const result = parseLogLine('\x1b[0m\x1b[1;33m[200][W][Net.cpp:5] warn():'); + expect(result).toEqual({ logLevel: 'W', deviceUptime: 200, tag: 'Net.cpp:5' }); + }); + }); + + describe('ESP-IDF format: LEVEL (uptimeMs) TAG:', () => { + it('parses error line', () => { + const result = parseLogLine('E (12345) wifi: connection failed'); + expect(result).toEqual({ logLevel: 'E', deviceUptime: 12345, tag: 'wifi' }); + }); + + it('parses warning line', () => { + const result = parseLogLine('W (500) httpd_parse: some warning'); + expect(result).toEqual({ logLevel: 'W', deviceUptime: 500, tag: 'httpd_parse' }); + }); + + it('parses info line', () => { + const result = parseLogLine('I (3000) MAIN: started'); + expect(result).toEqual({ logLevel: 'I', deviceUptime: 3000, tag: 'MAIN' }); + }); + + it('parses debug line', () => { + const result = parseLogLine('D (100) spi_flash: read 4096 bytes'); + expect(result).toEqual({ logLevel: 'D', deviceUptime: 100, tag: 'spi_flash' }); + }); + + it('parses verbose line', () => { + const result = parseLogLine('V (0) boot: init complete'); + expect(result).toEqual({ logLevel: 'V', deviceUptime: 0, tag: 'boot' }); + }); + + it('parses with leading ANSI escape', () => { + const result = parseLogLine('\x1b[0;32mI (12345) wifi: connected'); + expect(result).toEqual({ logLevel: 'I', deviceUptime: 12345, tag: 'wifi' }); + }); + }); + + describe('non-matching lines', () => { + it('returns null for plain text', () => { + expect(parseLogLine('hello world')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(parseLogLine('')).toBeNull(); + }); + + it('returns null for bootloader output', () => { + expect(parseLogLine('rst:0x1 (POWERON_RESET),boot:0x13')).toBeNull(); + }); + + it('returns null for bare ANSI', () => { + expect(parseLogLine('\x1b[0msome text')).toBeNull(); + }); + }); + + it('prefers OpenShock format over ESP-IDF', () => { + // A line that could only match OpenShock format + const result = parseLogLine('[100][I][tag] msg'); + expect(result).toEqual({ logLevel: 'I', deviceUptime: 100, tag: 'tag' }); + }); +}); + +describe('parseAnsi', () => { + it('returns plain text as single unstyled segment', () => { + const result = parseAnsi('hello world'); + expect(result).toEqual([{ text: 'hello world', style: {} }]); + }); + + it('returns empty text as single unstyled segment', () => { + const result = parseAnsi(''); + expect(result).toEqual([{ text: '', style: {} }]); + }); + + it('parses foreground color', () => { + const result = parseAnsi('\x1b[31mred text'); + expect(result).toEqual([{ text: 'red text', style: { color: '#ff0000' } }]); + }); + + it('parses background color', () => { + const result = parseAnsi('\x1b[42mgreen bg'); + expect(result).toEqual([{ text: 'green bg', style: { 'background-color': '#00ff00' } }]); + }); + + it('parses bold', () => { + const result = parseAnsi('\x1b[1mbold text'); + expect(result).toEqual([{ text: 'bold text', style: { 'font-weight': 'bold' } }]); + }); + + it('parses italic', () => { + const result = parseAnsi('\x1b[3mitalic text'); + expect(result).toEqual([{ text: 'italic text', style: { 'font-style': 'italic' } }]); + }); + + it('parses underline', () => { + const result = parseAnsi('\x1b[4munderlined'); + expect(result).toEqual([{ text: 'underlined', style: { 'text-decoration': 'underline' } }]); + }); + + it('resets style on code 0', () => { + const result = parseAnsi('\x1b[31mred\x1b[0m normal'); + expect(result).toEqual([ + { text: 'red', style: { color: '#ff0000' } }, + { text: ' normal', style: {} }, + ]); + }); + + it('handles combined codes in single sequence', () => { + const result = parseAnsi('\x1b[1;31mbold red'); + expect(result).toEqual([ + { text: 'bold red', style: { 'font-weight': 'bold', color: '#ff0000' } }, + ]); + }); + + it('handles multiple segments with different colors', () => { + const result = parseAnsi('\x1b[31mred\x1b[32mgreen\x1b[34mblue'); + expect(result).toEqual([ + { text: 'red', style: { color: '#ff0000' } }, + { text: 'green', style: { color: '#00ff00' } }, + { text: 'blue', style: { color: '#0000ff' } }, + ]); + }); + + it('handles text before first escape', () => { + const result = parseAnsi('prefix \x1b[31mred'); + expect(result).toEqual([ + { text: 'prefix ', style: {} }, + { text: 'red', style: { color: '#ff0000' } }, + ]); + }); + + it('parses bright colors', () => { + const result = parseAnsi('\x1b[91mbright red'); + expect(result).toEqual([{ text: 'bright red', style: { color: '#ff6b6b' } }]); + }); + + it('removes bold with code 22', () => { + const result = parseAnsi('\x1b[1mbold\x1b[22mnot bold'); + expect(result).toEqual([ + { text: 'bold', style: { 'font-weight': 'bold' } }, + { text: 'not bold', style: {} }, + ]); + }); + + it('handles bare reset sequence', () => { + const result = parseAnsi('\x1b[mtext'); + expect(result).toEqual([{ text: 'text', style: {} }]); + }); + + it('ignores non-SGR sequences', () => { + // \x1b[2J is a clear screen command (not 'm'), should be skipped + const result = parseAnsi('\x1b[2Jhello'); + expect(result).toEqual([{ text: 'hello', style: {} }]); + }); +}); + +describe('stripAnsi', () => { + it('returns plain text unchanged', () => { + expect(stripAnsi('hello world')).toBe('hello world'); + }); + + it('strips single escape', () => { + expect(stripAnsi('\x1b[31mred text')).toBe('red text'); + }); + + it('strips multiple escapes', () => { + expect(stripAnsi('\x1b[1;31mbold red\x1b[0m normal')).toBe('bold red normal'); + }); + + it('strips from OpenShock log line', () => { + expect(stripAnsi('\x1b[0;31m[56984][E][Config.cpp:185] SaveFromJSON():\x1b[0m')).toBe( + '[56984][E][Config.cpp:185] SaveFromJSON():' + ); + }); + + it('returns empty string unchanged', () => { + expect(stripAnsi('')).toBe(''); + }); +}); diff --git a/src/routes/flashtool/ansi.ts b/src/routes/flashtool/ansi.ts new file mode 100644 index 00000000..f0ab00fe --- /dev/null +++ b/src/routes/flashtool/ansi.ts @@ -0,0 +1,162 @@ +/** + * Lightweight ANSI escape sequence parser for ESP-IDF serial output. + * Converts ANSI SGR codes to inline CSS styles for rendering in the terminal. + * Also parses ESP-IDF log format for structured log level, uptime, and tag extraction. + */ + +export interface AnsiSegment { + text: string; + style: Record; +} + +export type LogLevel = 'E' | 'W' | 'I' | 'D' | 'V'; + +export interface ParsedLogLine { + logLevel: LogLevel; + deviceUptime: number; + tag: string; +} + +export const LOG_LEVEL_COLORS: Record = { + E: '#ff4444', + W: '#ffaa00', + I: '#51cf66', + D: '#74c0fc', + V: '#808080', +}; + +/** + * Parse log formats. Supports: + * - OpenShock: "[uptimeMs][LEVEL][file:line] message" + * - ESP-IDF: "LEVEL (uptimeMs) TAG: message" + * May be preceded by ANSI escape sequences. + */ +const ANSI_PREFIX = /^(?:\x1b\[[0-9;]*[a-zA-Z])*/; +const LOG_OPENSHOCK_REGEX = new RegExp( + ANSI_PREFIX.source + /\[(\d+)\]\[([EWDIV])\]\[([^\]]+)\]/.source +); +const LOG_ESPIDF_REGEX = new RegExp(ANSI_PREFIX.source + /([EWDIV]) \((\d+)\) ([^:]+):/.source); + +export function parseLogLine(text: string): ParsedLogLine | null { + let match = LOG_OPENSHOCK_REGEX.exec(text); + if (match) { + return { + logLevel: match[2] as LogLevel, + deviceUptime: parseInt(match[1], 10), + tag: match[3].trim(), + }; + } + match = LOG_ESPIDF_REGEX.exec(text); + if (match) { + return { + logLevel: match[1] as LogLevel, + deviceUptime: parseInt(match[2], 10), + tag: match[3].trim(), + }; + } + return null; +} + +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, ''); +} diff --git a/src/routes/flashtool/flash-context.svelte.ts b/src/routes/flashtool/flash-context.svelte.ts index 254da238..6e74b387 100644 --- a/src/routes/flashtool/flash-context.svelte.ts +++ b/src/routes/flashtool/flash-context.svelte.ts @@ -30,7 +30,7 @@ export class FlashContext { async connect(port: SerialPort): Promise { this.#connectFailed = false; - const m = await FlashManager.Connect(port, this.#terminal); + const m = await FlashManager.ConnectApplication(port, this.#terminal); this.#manager = m; this.#connectFailed = !m; }