feat: load precompiled firmware files (.hex, .bin, .elf) directly

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 <noreply@anthropic.com>
feature/load-precompiled-firmware
David Montero Crespo 2026-03-29 02:08:11 -03:00
parent e99ded70b5
commit 4e9bf09e65
2 changed files with 367 additions and 0 deletions

View File

@ -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<string[]>([]);
const [installModalOpen, setInstallModalOpen] = useState(false);
const importInputRef = useRef<HTMLInputElement>(null);
const firmwareInputRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [overflowOpen, setOverflowOpen] = useState(false);
const overflowMenuRef = useRef<HTMLDivElement>(null);
@ -262,6 +264,47 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
}
};
const handleFirmwareUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 */}
<input
ref={firmwareInputRef}
type="file"
accept=".hex,.bin,.elf,.ihex"
style={{ display: 'none' }}
onChange={handleFirmwareUpload}
/>
{/* Library Manager — always visible with label */}
<button
@ -484,6 +535,18 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
</svg>
Export zip
</button>
<div style={{ borderTop: '1px solid #3c3c3c', margin: '4px 0' }} />
<button
className="tb-overflow-item"
onClick={() => { firmwareInputRef.current?.click(); setOverflowOpen(false); }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
<line x1="12" y1="15" x2="12" y2="22" />
<polyline points="8 18 12 22 16 18" />
</svg>
Upload firmware (.hex, .bin, .elf)
</button>
</div>
)}
</div>

View File

@ -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<BoardKind>([
'arduino-uno', 'arduino-nano', 'arduino-mega', 'attiny85',
]);
const RP2040_BOARDS = new Set<BoardKind>([
'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<FirmwareLoadResult> {
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 };
}