From 4e9bf09e652aa72aad0c4bb783d35ebdaaa67fbe Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sun, 29 Mar 2026 02:08:11 -0300 Subject: [PATCH] feat: load precompiled firmware files (.hex, .bin, .elf) directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1 Add the ability to upload precompiled firmware files directly into the emulator, bypassing the built-in compilation step. This enables users with custom toolchains (ESP-IDF, PlatformIO, ASM workflows) to use Velxio purely as an emulation/debugging environment. New features: - "Upload firmware" option in the overflow menu (accepts .hex, .bin, .elf) - Automatic format detection from file extension and magic bytes - ELF parser extracts PT_LOAD segments and detects target architecture (AVR, ARM, RISC-V, Xtensa) from ELF e_machine header - Architecture mismatch warnings logged when ELF target differs from current board (non-blocking — upload proceeds anyway) - Firmware routed to the correct simulator loader via existing compileBoardProgram() — no simulator changes needed Supported formats per board: - AVR (Uno/Nano/Mega/ATtiny85): .hex (direct), .elf (parsed → HEX) - RP2040 (Pico): .bin (direct), .elf (parsed → binary) - ESP32-C3: .bin (direct), .hex, .elf (parsed → binary) - ESP32/S3 (QEMU): .bin (direct), .elf (parsed → binary) New file: frontend/src/utils/firmwareLoader.ts Co-Authored-By: Claude Opus 4.6 --- .../src/components/editor/EditorToolbar.tsx | 63 ++++ frontend/src/utils/firmwareLoader.ts | 304 ++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 frontend/src/utils/firmwareLoader.ts diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 07cbb6a..af70320 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -11,6 +11,7 @@ import { InstallLibrariesModal } from '../simulator/InstallLibrariesModal'; import { parseCompileResult } from '../../utils/compilationLogger'; import type { CompilationLog } from '../../utils/compilationLogger'; import { exportToWokwiZip, importFromWokwiZip } from '../../utils/wokwiZip'; +import { readFirmwareFile } from '../../utils/firmwareLoader'; import { trackCompileCode, trackRunSimulation, trackStopSimulation, trackResetSimulation, trackOpenLibraryManager } from '../../utils/analytics'; import './EditorToolbar.css'; @@ -67,6 +68,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi const [pendingLibraries, setPendingLibraries] = useState([]); const [installModalOpen, setInstallModalOpen] = useState(false); const importInputRef = useRef(null); + const firmwareInputRef = useRef(null); const toolbarRef = useRef(null); const [overflowOpen, setOverflowOpen] = useState(false); const overflowMenuRef = useRef(null); @@ -262,6 +264,47 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi } }; + const handleFirmwareUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (firmwareInputRef.current) firmwareInputRef.current.value = ''; + if (!file) return; + + setConsoleOpen(true); + addLog({ timestamp: new Date(), type: 'info', message: `Loading firmware: ${file.name}...` }); + + try { + const boardKind = activeBoard?.boardKind; + if (!boardKind) { + setMessage({ type: 'error', text: 'No board selected' }); + return; + } + + const result = await readFirmwareFile(file, boardKind); + + // Architecture mismatch warning for ELF files + if (result.elfInfo?.suggestedBoard && result.elfInfo.suggestedBoard !== boardKind) { + const detected = result.elfInfo.architectureName; + const current = activeBoard ? BOARD_KIND_LABELS[activeBoard.boardKind] : boardKind; + addLog({ + timestamp: new Date(), + type: 'info', + message: `Note: Detected ${detected} architecture, but current board is ${current}. Loading anyway.`, + }); + } + + if (activeBoardId) { + compileBoardProgram(activeBoardId, result.program); + markCompiled(); + addLog({ timestamp: new Date(), type: 'info', message: result.message }); + setMessage({ type: 'success', text: `Firmware loaded: ${file.name}` }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : 'Failed to load firmware'; + addLog({ timestamp: new Date(), type: 'error', message: errMsg }); + setMessage({ type: 'error', text: errMsg }); + } + }; + const handleImportFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!importInputRef.current) return; @@ -431,6 +474,14 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi style={{ display: 'none' }} onChange={handleImportFile} /> + {/* Hidden file input for firmware upload */} + {/* Library Manager — always visible with label */} +
+
)} diff --git a/frontend/src/utils/firmwareLoader.ts b/frontend/src/utils/firmwareLoader.ts new file mode 100644 index 0000000..6fafe42 --- /dev/null +++ b/frontend/src/utils/firmwareLoader.ts @@ -0,0 +1,304 @@ +/** + * Firmware file loader — reads .hex, .bin, and .elf files and converts them + * into the string format expected by compileBoardProgram(). + * + * - AVR boards expect Intel HEX text + * - RP2040 boards expect base64-encoded raw binary + * - ESP32 boards expect base64-encoded binary (merged flash image or raw app) + */ + +import type { BoardKind } from '../types/board'; + +// ── Format detection ───────────────────────────────────────────────────────── + +export type FirmwareFormat = 'hex' | 'bin' | 'elf'; + +const ELF_MAGIC = [0x7f, 0x45, 0x4c, 0x46]; // \x7FELF + +export function detectFirmwareFormat(filename: string, bytes: Uint8Array): FirmwareFormat { + // Check ELF magic + if (bytes.length >= 4 && + bytes[0] === ELF_MAGIC[0] && bytes[1] === ELF_MAGIC[1] && + bytes[2] === ELF_MAGIC[2] && bytes[3] === ELF_MAGIC[3]) { + return 'elf'; + } + + // Check file extension + const ext = filename.toLowerCase().split('.').pop() ?? ''; + if (ext === 'hex' || ext === 'ihex') return 'hex'; + if (ext === 'elf') return 'elf'; + + // Check if content looks like Intel HEX (first non-empty line starts with ':') + const firstByte = bytes[0]; + if (firstByte === 0x3a) return 'hex'; // ':' character + + return 'bin'; +} + +// ── ELF architecture detection ─────────────────────────────────────────────── + +// ELF e_machine values +const EM_ARM = 0x28; +const EM_AVR = 0x53; +const EM_XTENSA = 0x5e; +const EM_RISCV = 0xf3; + +export interface ElfInfo { + machine: number; + is32bit: boolean; + isLittleEndian: boolean; + suggestedBoard: BoardKind | null; + architectureName: string; +} + +export function detectArchitectureFromElf(bytes: Uint8Array): ElfInfo | null { + if (bytes.length < 20) return null; + if (bytes[0] !== 0x7f || bytes[1] !== 0x45 || bytes[2] !== 0x4c || bytes[3] !== 0x46) return null; + + const is32bit = bytes[4] === 1; + const isLittleEndian = bytes[5] === 1; + + // e_machine at offset 18 (2 bytes) + const machine = isLittleEndian + ? bytes[18] | (bytes[19] << 8) + : (bytes[18] << 8) | bytes[19]; + + let suggestedBoard: BoardKind | null = null; + let architectureName = 'Unknown'; + + switch (machine) { + case EM_AVR: + suggestedBoard = 'arduino-uno'; + architectureName = 'AVR'; + break; + case EM_ARM: + suggestedBoard = 'raspberry-pi-pico'; + architectureName = 'ARM'; + break; + case EM_RISCV: + suggestedBoard = 'esp32-c3'; + architectureName = 'RISC-V'; + break; + case EM_XTENSA: + suggestedBoard = 'esp32'; + architectureName = 'Xtensa'; + break; + } + + return { machine, is32bit, isLittleEndian, suggestedBoard, architectureName }; +} + +// ── ELF program extraction ─────────────────────────────────────────────────── + +/** + * Extract loadable (PT_LOAD) segments from a 32-bit ELF file. + * Returns a flat binary image starting at the lowest physical address. + */ +export function extractLoadSegmentsFromElf(bytes: Uint8Array): Uint8Array { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const is32bit = bytes[4] === 1; + const isLE = bytes[5] === 1; + + if (!is32bit) { + throw new Error('Only 32-bit ELF files are supported'); + } + + const u16 = (off: number) => isLE ? view.getUint16(off, true) : view.getUint16(off, false); + const u32 = (off: number) => isLE ? view.getUint32(off, true) : view.getUint32(off, false); + + // ELF32 header fields + const e_phoff = u32(28); // program header table offset + const e_phentsize = u16(42); // program header entry size + const e_phnum = u16(44); // number of program header entries + + if (e_phoff === 0 || e_phnum === 0) { + throw new Error('ELF file has no program headers'); + } + + // Collect PT_LOAD segments + const PT_LOAD = 1; + const segments: { paddr: number; data: Uint8Array }[] = []; + + for (let i = 0; i < e_phnum; i++) { + const phOff = e_phoff + i * e_phentsize; + if (phOff + e_phentsize > bytes.length) break; + + const p_type = u32(phOff); + if (p_type !== PT_LOAD) continue; + + const p_offset = u32(phOff + 4); + const p_paddr = u32(phOff + 12); + const p_filesz = u32(phOff + 16); + + if (p_filesz === 0) continue; + if (p_offset + p_filesz > bytes.length) { + throw new Error(`ELF segment at offset 0x${p_offset.toString(16)} extends beyond file`); + } + + segments.push({ + paddr: p_paddr, + data: bytes.slice(p_offset, p_offset + p_filesz), + }); + } + + if (segments.length === 0) { + throw new Error('No loadable segments found in ELF file'); + } + + // Sort by physical address and create flat binary + segments.sort((a, b) => a.paddr - b.paddr); + const baseAddr = segments[0].paddr; + const lastSeg = segments[segments.length - 1]; + const totalSize = (lastSeg.paddr - baseAddr) + lastSeg.data.length; + const result = new Uint8Array(totalSize); + + for (const seg of segments) { + result.set(seg.data, seg.paddr - baseAddr); + } + + return result; +} + +// ── Binary ↔ Intel HEX conversion ─────────────────────────────────────────── + +/** Convert a flat binary to Intel HEX text format (16 bytes per data record). */ +export function binaryToIntelHex(data: Uint8Array): string { + const lines: string[] = []; + const BYTES_PER_LINE = 16; + + for (let addr = 0; addr < data.length; addr += BYTES_PER_LINE) { + const count = Math.min(BYTES_PER_LINE, data.length - addr); + let line = ':'; + + // Byte count + line += count.toString(16).padStart(2, '0').toUpperCase(); + // Address (16-bit) + line += (addr & 0xffff).toString(16).padStart(4, '0').toUpperCase(); + // Record type 0x00 = data + line += '00'; + + let checksum = count + ((addr >> 8) & 0xff) + (addr & 0xff) + 0x00; + for (let i = 0; i < count; i++) { + const b = data[addr + i]; + line += b.toString(16).padStart(2, '0').toUpperCase(); + checksum += b; + } + + // Two's complement checksum + line += ((~checksum + 1) & 0xff).toString(16).padStart(2, '0').toUpperCase(); + lines.push(line); + } + + // EOF record + lines.push(':00000001FF'); + return lines.join('\n'); +} + +/** Convert ArrayBuffer to base64 string. */ +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +// ── Board classification helpers ───────────────────────────────────────────── + +const AVR_BOARDS = new Set([ + 'arduino-uno', 'arduino-nano', 'arduino-mega', 'attiny85', +]); + +const RP2040_BOARDS = new Set([ + 'raspberry-pi-pico', 'pi-pico-w', +]); + +function isAvrBoard(kind: BoardKind): boolean { + return AVR_BOARDS.has(kind); +} + +function isRp2040Board(kind: BoardKind): boolean { + return RP2040_BOARDS.has(kind); +} + +// ── Main entry point ───────────────────────────────────────────────────────── + +export interface FirmwareLoadResult { + /** Program string ready for compileBoardProgram() */ + program: string; + /** Detected format */ + format: FirmwareFormat; + /** ELF info if available */ + elfInfo: ElfInfo | null; + /** Human-readable status */ + message: string; +} + +const MAX_FILE_SIZE = 16 * 1024 * 1024; // 16 MB absolute max + +/** + * Read a firmware file and convert it to the format expected by compileBoardProgram(). + * + * @param file - The File object from the file input + * @param boardKind - The current board's kind (determines output format) + * @returns The program string + metadata + */ +export async function readFirmwareFile(file: File, boardKind: BoardKind): Promise { + if (file.size > MAX_FILE_SIZE) { + throw new Error(`File too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Max ${MAX_FILE_SIZE / 1024 / 1024} MB.`); + } + + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const format = detectFirmwareFormat(file.name, bytes); + + let elfInfo: ElfInfo | null = null; + let program: string; + let message: string; + + switch (format) { + case 'hex': { + // Intel HEX — read as text + const text = new TextDecoder().decode(bytes); + if (isAvrBoard(boardKind)) { + // AVR/RISC-V: pass HEX text directly + program = text; + } else { + // Non-AVR boards: we could parse HEX → binary → base64, but loadHex also exists + // for ESP32-C3 and RISC-V. Pass as text and let compileBoardProgram route it. + program = text; + } + message = `Loaded Intel HEX firmware (${(file.size / 1024).toFixed(1)} KB)`; + break; + } + + case 'bin': { + // Raw binary — convert to base64 + program = arrayBufferToBase64(buffer); + message = `Loaded binary firmware (${(file.size / 1024).toFixed(1)} KB)`; + break; + } + + case 'elf': { + elfInfo = detectArchitectureFromElf(bytes); + const archName = elfInfo?.architectureName ?? 'unknown'; + + // Extract loadable segments + const loadData = extractLoadSegmentsFromElf(bytes); + + if (isAvrBoard(boardKind)) { + // AVR needs Intel HEX text + program = binaryToIntelHex(loadData); + message = `Loaded ELF firmware (${archName}, ${(file.size / 1024).toFixed(1)} KB) → Intel HEX`; + } else { + // RP2040/ESP32 need base64 binary + program = arrayBufferToBase64(loadData.buffer); + message = `Loaded ELF firmware (${archName}, ${(file.size / 1024).toFixed(1)} KB) → binary`; + } + break; + } + } + + return { program, format, elfInfo, message }; +}