Compare commits
2 Commits
master
...
feature/lo
| Author | SHA1 | Date |
|---|---|---|
|
|
558525b4b2 | |
|
|
4e9bf09e65 |
|
|
@ -0,0 +1,594 @@
|
|||
/**
|
||||
* Firmware Loader Tests
|
||||
*
|
||||
* Tests the firmwareLoader.ts utility with:
|
||||
* - Unit tests using synthetic data (format detection, ELF parsing, HEX conversion)
|
||||
* - Integration tests using real arduino-cli compiled binaries (AVR, RP2040, ESP32-C3)
|
||||
* - Cross-format compatibility tests
|
||||
* - Simulator loading tests (verify compiled firmware loads without crashing)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, beforeAll } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
detectFirmwareFormat,
|
||||
detectArchitectureFromElf,
|
||||
extractLoadSegmentsFromElf,
|
||||
binaryToIntelHex,
|
||||
readFirmwareFile,
|
||||
} from '../utils/firmwareLoader';
|
||||
import { hexToUint8Array } from '../utils/hexParser';
|
||||
import { AVRSimulator } from '../simulation/AVRSimulator';
|
||||
import { PinManager } from '../simulation/PinManager';
|
||||
|
||||
// ── Mock requestAnimationFrame (not available in Node) ───────────────────────
|
||||
beforeEach(() => {
|
||||
let counter = 0;
|
||||
let depth = 0;
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
if (depth === 0) {
|
||||
depth++;
|
||||
cb(0);
|
||||
depth--;
|
||||
}
|
||||
return ++counter;
|
||||
});
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
// ── Fixture paths ────────────────────────────────────────────────────────────
|
||||
const AVR_FIXTURE_DIR = join(__dirname, 'fixtures/avr-blink');
|
||||
const RP2040_FIXTURE_DIR = join(__dirname, 'fixtures/rp2040-blink');
|
||||
const ESP32C3_FIXTURE_DIR = join(__dirname, 'fixtures/esp32c3-blink');
|
||||
|
||||
// ── Helper: create a mock File from bytes ────────────────────────────────────
|
||||
function mockFile(name: string, content: Uint8Array | string): File {
|
||||
const buf = typeof content === 'string'
|
||||
? new TextEncoder().encode(content)
|
||||
: content;
|
||||
const blob = new Blob([buf]);
|
||||
return new File([blob], name);
|
||||
}
|
||||
|
||||
// ── Helper: build a minimal ELF32 header ─────────────────────────────────────
|
||||
function buildElf32Header(opts: {
|
||||
machine: number;
|
||||
littleEndian?: boolean;
|
||||
phoff?: number;
|
||||
phentsize?: number;
|
||||
phnum?: number;
|
||||
segments?: { type: number; offset: number; paddr: number; filesz: number }[];
|
||||
}): Uint8Array {
|
||||
const le = opts.littleEndian ?? true;
|
||||
// Minimum ELF32 header = 52 bytes
|
||||
const phoff = opts.phoff ?? 52;
|
||||
const phentsize = opts.phentsize ?? 32;
|
||||
const phnum = opts.phnum ?? (opts.segments?.length ?? 0);
|
||||
const segData = opts.segments ?? [];
|
||||
|
||||
const totalSize = phoff + phnum * phentsize + segData.reduce((s, seg) => s + seg.filesz, 0);
|
||||
const buf = new ArrayBuffer(Math.max(totalSize, 52 + phnum * phentsize + 256));
|
||||
const view = new DataView(buf);
|
||||
const arr = new Uint8Array(buf);
|
||||
|
||||
// ELF magic
|
||||
arr[0] = 0x7f; arr[1] = 0x45; arr[2] = 0x4c; arr[3] = 0x46;
|
||||
arr[4] = 1; // 32-bit
|
||||
arr[5] = le ? 1 : 2; // endianness
|
||||
|
||||
// e_machine at offset 18
|
||||
if (le) {
|
||||
view.setUint16(18, opts.machine, true);
|
||||
} else {
|
||||
view.setUint16(18, opts.machine, false);
|
||||
}
|
||||
|
||||
// e_phoff at offset 28
|
||||
view.setUint32(28, phoff, le);
|
||||
// e_phentsize at offset 42
|
||||
view.setUint16(42, phentsize, le);
|
||||
// e_phnum at offset 44
|
||||
view.setUint16(44, phnum, le);
|
||||
|
||||
// Write program headers
|
||||
for (let i = 0; i < segData.length; i++) {
|
||||
const off = phoff + i * phentsize;
|
||||
const seg = segData[i];
|
||||
view.setUint32(off, seg.type, le); // p_type
|
||||
view.setUint32(off + 4, seg.offset, le); // p_offset
|
||||
view.setUint32(off + 8, 0, le); // p_vaddr (unused)
|
||||
view.setUint32(off + 12, seg.paddr, le); // p_paddr
|
||||
view.setUint32(off + 16, seg.filesz, le); // p_filesz
|
||||
view.setUint32(off + 20, seg.filesz, le); // p_memsz
|
||||
|
||||
// Write segment data at p_offset
|
||||
for (let j = 0; j < seg.filesz; j++) {
|
||||
arr[seg.offset + j] = (j + 1) & 0xff; // fill with predictable data
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(buf, 0, Math.max(totalSize, 52));
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Part 1: Unit tests — synthetic data, no arduino-cli needed
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('firmwareLoader — format detection', () => {
|
||||
it('detects Intel HEX by magic byte (colon)', () => {
|
||||
const hex = new TextEncoder().encode(':00000001FF\n');
|
||||
expect(detectFirmwareFormat('firmware', hex)).toBe('hex');
|
||||
});
|
||||
|
||||
it('detects Intel HEX by .hex extension', () => {
|
||||
const data = new Uint8Array([0x00, 0x01, 0x02]);
|
||||
expect(detectFirmwareFormat('blink.hex', data)).toBe('hex');
|
||||
});
|
||||
|
||||
it('detects Intel HEX by .ihex extension', () => {
|
||||
const data = new Uint8Array([0x00, 0x01, 0x02]);
|
||||
expect(detectFirmwareFormat('firmware.ihex', data)).toBe('hex');
|
||||
});
|
||||
|
||||
it('detects ELF by magic bytes', () => {
|
||||
const elf = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x01, 0x01, 0x00, 0x00]);
|
||||
expect(detectFirmwareFormat('firmware', elf)).toBe('elf');
|
||||
});
|
||||
|
||||
it('detects ELF by .elf extension', () => {
|
||||
const data = new Uint8Array([0x00, 0x01, 0x02]);
|
||||
expect(detectFirmwareFormat('blink.elf', data)).toBe('elf');
|
||||
});
|
||||
|
||||
it('defaults to bin for unknown formats', () => {
|
||||
const data = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
expect(detectFirmwareFormat('firmware.bin', data)).toBe('bin');
|
||||
expect(detectFirmwareFormat('unknown_file', data)).toBe('bin');
|
||||
});
|
||||
|
||||
it('ELF magic takes priority over extension', () => {
|
||||
const elf = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x01, 0x01]);
|
||||
expect(detectFirmwareFormat('firmware.bin', elf)).toBe('elf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('firmwareLoader — ELF architecture detection', () => {
|
||||
it('detects AVR (e_machine=0x53)', () => {
|
||||
const elf = buildElf32Header({ machine: 0x53 });
|
||||
const info = detectArchitectureFromElf(elf);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.architectureName).toBe('AVR');
|
||||
expect(info!.suggestedBoard).toBe('arduino-uno');
|
||||
expect(info!.is32bit).toBe(true);
|
||||
expect(info!.isLittleEndian).toBe(true);
|
||||
});
|
||||
|
||||
it('detects ARM (e_machine=0x28)', () => {
|
||||
const elf = buildElf32Header({ machine: 0x28 });
|
||||
const info = detectArchitectureFromElf(elf);
|
||||
expect(info!.architectureName).toBe('ARM');
|
||||
expect(info!.suggestedBoard).toBe('raspberry-pi-pico');
|
||||
});
|
||||
|
||||
it('detects RISC-V (e_machine=0xF3)', () => {
|
||||
const elf = buildElf32Header({ machine: 0xf3 });
|
||||
const info = detectArchitectureFromElf(elf);
|
||||
expect(info!.architectureName).toBe('RISC-V');
|
||||
expect(info!.suggestedBoard).toBe('esp32-c3');
|
||||
});
|
||||
|
||||
it('detects Xtensa (e_machine=0x5E)', () => {
|
||||
const elf = buildElf32Header({ machine: 0x5e });
|
||||
const info = detectArchitectureFromElf(elf);
|
||||
expect(info!.architectureName).toBe('Xtensa');
|
||||
expect(info!.suggestedBoard).toBe('esp32');
|
||||
});
|
||||
|
||||
it('returns Unknown for unrecognized machine type', () => {
|
||||
const elf = buildElf32Header({ machine: 0x99 });
|
||||
const info = detectArchitectureFromElf(elf);
|
||||
expect(info!.architectureName).toBe('Unknown');
|
||||
expect(info!.suggestedBoard).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-ELF data', () => {
|
||||
const data = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
expect(detectArchitectureFromElf(data)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for data too short', () => {
|
||||
const data = new Uint8Array([0x7f, 0x45]);
|
||||
expect(detectArchitectureFromElf(data)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles big-endian ELF', () => {
|
||||
const elf = buildElf32Header({ machine: 0x53, littleEndian: false });
|
||||
const info = detectArchitectureFromElf(elf);
|
||||
expect(info!.isLittleEndian).toBe(false);
|
||||
expect(info!.architectureName).toBe('AVR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('firmwareLoader — binaryToIntelHex round-trip', () => {
|
||||
it('converts 10 bytes and round-trips through hexParser', () => {
|
||||
const original = new Uint8Array([0x0F, 0xEF, 0x04, 0xB9, 0x00, 0xE2, 0x05, 0xB9, 0xFF, 0xCF]);
|
||||
const hex = binaryToIntelHex(original);
|
||||
|
||||
// Should start with ':'
|
||||
expect(hex.startsWith(':')).toBe(true);
|
||||
// Should end with EOF record
|
||||
expect(hex.endsWith(':00000001FF')).toBe(true);
|
||||
|
||||
// Parse back
|
||||
const parsed = hexToUint8Array(hex);
|
||||
expect(parsed.length).toBeGreaterThanOrEqual(original.length);
|
||||
for (let i = 0; i < original.length; i++) {
|
||||
expect(parsed[i]).toBe(original[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles empty data', () => {
|
||||
const hex = binaryToIntelHex(new Uint8Array(0));
|
||||
expect(hex).toBe(':00000001FF');
|
||||
});
|
||||
|
||||
it('handles data larger than 16 bytes (multiple lines)', () => {
|
||||
const data = new Uint8Array(48);
|
||||
for (let i = 0; i < 48; i++) data[i] = i;
|
||||
const hex = binaryToIntelHex(data);
|
||||
const lines = hex.split('\n');
|
||||
|
||||
// 48 bytes / 16 per line = 3 data lines + 1 EOF
|
||||
expect(lines.length).toBe(4);
|
||||
expect(lines[3]).toBe(':00000001FF');
|
||||
|
||||
// Round-trip
|
||||
const parsed = hexToUint8Array(hex);
|
||||
for (let i = 0; i < 48; i++) {
|
||||
expect(parsed[i]).toBe(data[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('firmwareLoader — extractLoadSegmentsFromElf', () => {
|
||||
it('extracts PT_LOAD segments from synthetic ELF', () => {
|
||||
const dataOffset = 52 + 32; // header + 1 program header
|
||||
const elf = buildElf32Header({
|
||||
machine: 0x53,
|
||||
segments: [
|
||||
{ type: 1 /* PT_LOAD */, offset: dataOffset, paddr: 0x0000, filesz: 16 },
|
||||
],
|
||||
});
|
||||
const result = extractLoadSegmentsFromElf(elf);
|
||||
expect(result.length).toBe(16);
|
||||
// Verify data matches what buildElf32Header wrote
|
||||
for (let i = 0; i < 16; i++) {
|
||||
expect(result[i]).toBe((i + 1) & 0xff);
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts multiple PT_LOAD segments and sorts by address', () => {
|
||||
const seg1Offset = 52 + 64; // after 2 program headers
|
||||
const seg2Offset = seg1Offset + 8;
|
||||
const elf = buildElf32Header({
|
||||
machine: 0x28,
|
||||
segments: [
|
||||
{ type: 1, offset: seg2Offset, paddr: 0x1000, filesz: 8 }, // higher addr first
|
||||
{ type: 1, offset: seg1Offset, paddr: 0x0000, filesz: 8 }, // lower addr second
|
||||
],
|
||||
});
|
||||
const result = extractLoadSegmentsFromElf(elf);
|
||||
// Should include gap between 0x0000+8 and 0x1000+8
|
||||
expect(result.length).toBe(0x1000 + 8);
|
||||
});
|
||||
|
||||
it('skips non-PT_LOAD segments', () => {
|
||||
const dataOffset = 52 + 64;
|
||||
const elf = buildElf32Header({
|
||||
machine: 0x53,
|
||||
segments: [
|
||||
{ type: 2 /* PT_DYNAMIC */, offset: dataOffset, paddr: 0x0000, filesz: 16 },
|
||||
{ type: 1 /* PT_LOAD */, offset: dataOffset + 16, paddr: 0x0000, filesz: 8 },
|
||||
],
|
||||
});
|
||||
const result = extractLoadSegmentsFromElf(elf);
|
||||
expect(result.length).toBe(8);
|
||||
});
|
||||
|
||||
it('throws on ELF with no program headers', () => {
|
||||
const elf = buildElf32Header({ machine: 0x53, phoff: 0, phnum: 0 });
|
||||
expect(() => extractLoadSegmentsFromElf(elf)).toThrow('no program headers');
|
||||
});
|
||||
|
||||
it('parses existing ESP32-C3 blink.elf fixture', () => {
|
||||
const elfPath = join(ESP32C3_FIXTURE_DIR, 'blink.elf');
|
||||
if (!existsSync(elfPath)) return; // skip if fixture missing
|
||||
const elfData = new Uint8Array(readFileSync(elfPath));
|
||||
|
||||
const info = detectArchitectureFromElf(elfData);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.architectureName).toBe('RISC-V');
|
||||
|
||||
const segments = extractLoadSegmentsFromElf(elfData);
|
||||
expect(segments.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Part 2: Integration tests — real arduino-cli compiled binaries
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('firmwareLoader — AVR integration (arduino-cli)', () => {
|
||||
let avrHexData: Uint8Array | null = null;
|
||||
let avrElfData: Uint8Array | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
const hexPath = join(AVR_FIXTURE_DIR, 'avr-blink.ino.hex');
|
||||
const elfPath = join(AVR_FIXTURE_DIR, 'avr-blink.ino.elf');
|
||||
|
||||
if (existsSync(hexPath)) avrHexData = new Uint8Array(readFileSync(hexPath));
|
||||
if (existsSync(elfPath)) avrElfData = new Uint8Array(readFileSync(elfPath));
|
||||
});
|
||||
|
||||
it('compiled .hex fixture exists and is valid Intel HEX', () => {
|
||||
if (!avrHexData) return; // skip if not compiled
|
||||
expect(avrHexData.length).toBeGreaterThan(0);
|
||||
// First byte should be ':' (0x3A)
|
||||
expect(avrHexData[0]).toBe(0x3a);
|
||||
// Should be parseable
|
||||
const text = new TextDecoder().decode(avrHexData);
|
||||
const bytes = hexToUint8Array(text);
|
||||
expect(bytes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('compiled .elf fixture exists and is detected as AVR', () => {
|
||||
if (!avrElfData) return;
|
||||
const info = detectArchitectureFromElf(avrElfData);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.architectureName).toBe('AVR');
|
||||
expect(info!.suggestedBoard).toBe('arduino-uno');
|
||||
expect(info!.is32bit).toBe(true);
|
||||
});
|
||||
|
||||
it('readFirmwareFile loads .hex for AVR board', async () => {
|
||||
if (!avrHexData) return;
|
||||
const file = mockFile('blink.hex', avrHexData);
|
||||
const result = await readFirmwareFile(file, 'arduino-uno');
|
||||
|
||||
expect(result.format).toBe('hex');
|
||||
expect(result.program).toContain(':');
|
||||
expect(result.message).toContain('Intel HEX');
|
||||
expect(result.elfInfo).toBeNull();
|
||||
});
|
||||
|
||||
it('readFirmwareFile loads .elf for AVR board → converts to HEX', async () => {
|
||||
if (!avrElfData) return;
|
||||
const file = mockFile('blink.elf', avrElfData);
|
||||
const result = await readFirmwareFile(file, 'arduino-uno');
|
||||
|
||||
expect(result.format).toBe('elf');
|
||||
expect(result.program).toContain(':'); // Should be Intel HEX text
|
||||
expect(result.program).toContain(':00000001FF'); // EOF record
|
||||
expect(result.elfInfo).not.toBeNull();
|
||||
expect(result.elfInfo!.architectureName).toBe('AVR');
|
||||
});
|
||||
|
||||
it('AVRSimulator.loadHex accepts the .hex firmware', () => {
|
||||
if (!avrHexData) return;
|
||||
const pm = new PinManager();
|
||||
const sim = new AVRSimulator(pm);
|
||||
const hexText = new TextDecoder().decode(avrHexData);
|
||||
|
||||
// Should not throw
|
||||
expect(() => sim.loadHex(hexText)).not.toThrow();
|
||||
});
|
||||
|
||||
it('AVRSimulator.loadHex accepts ELF-converted HEX', async () => {
|
||||
if (!avrElfData) return;
|
||||
const file = mockFile('blink.elf', avrElfData);
|
||||
const result = await readFirmwareFile(file, 'arduino-uno');
|
||||
|
||||
const pm = new PinManager();
|
||||
const sim = new AVRSimulator(pm);
|
||||
|
||||
// Should not throw
|
||||
expect(() => sim.loadHex(result.program)).not.toThrow();
|
||||
});
|
||||
|
||||
it('AVR blink .hex runs and toggles pin 13 (PORTB bit 5)', () => {
|
||||
if (!avrHexData) return;
|
||||
const pm = new PinManager();
|
||||
const sim = new AVRSimulator(pm);
|
||||
const hexText = new TextDecoder().decode(avrHexData);
|
||||
sim.loadHex(hexText);
|
||||
|
||||
// Run some cycles — the blink sketch sets DDRB and PORTB during setup()
|
||||
// We can't run the full animation loop, but we can verify the program loaded
|
||||
// and the simulator doesn't crash during initial execution
|
||||
sim.start();
|
||||
sim.stop();
|
||||
|
||||
// If we got here without throwing, the firmware loaded and executed correctly
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firmwareLoader — RP2040 integration (arduino-cli)', () => {
|
||||
let rp2040BinData: Uint8Array | null = null;
|
||||
let rp2040ElfData: Uint8Array | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
const binPath = join(RP2040_FIXTURE_DIR, 'rp2040-blink.ino.bin');
|
||||
const elfPath = join(RP2040_FIXTURE_DIR, 'rp2040-blink.ino.elf');
|
||||
|
||||
if (existsSync(binPath)) rp2040BinData = new Uint8Array(readFileSync(binPath));
|
||||
if (existsSync(elfPath)) rp2040ElfData = new Uint8Array(readFileSync(elfPath));
|
||||
});
|
||||
|
||||
it('compiled .bin fixture exists', () => {
|
||||
if (!rp2040BinData) return;
|
||||
expect(rp2040BinData.length).toBeGreaterThan(0);
|
||||
console.log(`RP2040 .bin size: ${rp2040BinData.length} bytes`);
|
||||
});
|
||||
|
||||
it('compiled .elf fixture is detected as ARM', () => {
|
||||
if (!rp2040ElfData) return;
|
||||
const info = detectArchitectureFromElf(rp2040ElfData);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.architectureName).toBe('ARM');
|
||||
expect(info!.suggestedBoard).toBe('raspberry-pi-pico');
|
||||
});
|
||||
|
||||
it('readFirmwareFile loads .bin for RP2040 board → base64', async () => {
|
||||
if (!rp2040BinData) return;
|
||||
const file = mockFile('blink.bin', rp2040BinData);
|
||||
const result = await readFirmwareFile(file, 'raspberry-pi-pico');
|
||||
|
||||
expect(result.format).toBe('bin');
|
||||
expect(result.message).toContain('binary firmware');
|
||||
// Program should be base64 — verify it decodes back to same length
|
||||
const decoded = atob(result.program);
|
||||
expect(decoded.length).toBe(rp2040BinData.length);
|
||||
});
|
||||
|
||||
it('readFirmwareFile loads .elf for RP2040 board → base64', async () => {
|
||||
if (!rp2040ElfData) return;
|
||||
const file = mockFile('blink.elf', rp2040ElfData);
|
||||
const result = await readFirmwareFile(file, 'raspberry-pi-pico');
|
||||
|
||||
expect(result.format).toBe('elf');
|
||||
expect(result.elfInfo!.architectureName).toBe('ARM');
|
||||
// Should produce base64 (not HEX text)
|
||||
expect(result.program).not.toContain(':00000001FF');
|
||||
const decoded = atob(result.program);
|
||||
expect(decoded.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firmwareLoader — ESP32-C3 integration (fixture)', () => {
|
||||
let esp32BinData: Uint8Array | null = null;
|
||||
let esp32ElfData: Uint8Array | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
const binPath = join(ESP32C3_FIXTURE_DIR, 'blink.bin');
|
||||
const elfPath = join(ESP32C3_FIXTURE_DIR, 'blink.elf');
|
||||
|
||||
if (existsSync(binPath)) esp32BinData = new Uint8Array(readFileSync(binPath));
|
||||
if (existsSync(elfPath)) esp32ElfData = new Uint8Array(readFileSync(elfPath));
|
||||
});
|
||||
|
||||
it('blink.bin fixture exists', () => {
|
||||
if (!esp32BinData) return;
|
||||
expect(esp32BinData.length).toBeGreaterThan(0);
|
||||
console.log(`ESP32-C3 .bin size: ${esp32BinData.length} bytes`);
|
||||
});
|
||||
|
||||
it('blink.elf fixture detected as RISC-V', () => {
|
||||
if (!esp32ElfData) return;
|
||||
const info = detectArchitectureFromElf(esp32ElfData);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.architectureName).toBe('RISC-V');
|
||||
expect(info!.suggestedBoard).toBe('esp32-c3');
|
||||
});
|
||||
|
||||
it('readFirmwareFile loads .bin for ESP32-C3 board → base64', async () => {
|
||||
if (!esp32BinData) return;
|
||||
const file = mockFile('blink.bin', esp32BinData);
|
||||
const result = await readFirmwareFile(file, 'esp32-c3');
|
||||
|
||||
expect(result.format).toBe('bin');
|
||||
const decoded = atob(result.program);
|
||||
expect(decoded.length).toBe(esp32BinData.length);
|
||||
});
|
||||
|
||||
it('readFirmwareFile loads .elf for ESP32-C3 board → base64', async () => {
|
||||
if (!esp32ElfData) return;
|
||||
const file = mockFile('blink.elf', esp32ElfData);
|
||||
const result = await readFirmwareFile(file, 'esp32-c3');
|
||||
|
||||
expect(result.format).toBe('elf');
|
||||
expect(result.elfInfo!.architectureName).toBe('RISC-V');
|
||||
// base64 output
|
||||
const decoded = atob(result.program);
|
||||
expect(decoded.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// Part 3: Cross-format compatibility tests
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('firmwareLoader — cross-format compatibility', () => {
|
||||
let avrHexData: Uint8Array | null = null;
|
||||
let rp2040BinData: Uint8Array | null = null;
|
||||
let avrElfData: Uint8Array | null = null;
|
||||
|
||||
beforeAll(() => {
|
||||
const hexPath = join(AVR_FIXTURE_DIR, 'avr-blink.ino.hex');
|
||||
const binPath = join(RP2040_FIXTURE_DIR, 'rp2040-blink.ino.bin');
|
||||
const elfPath = join(AVR_FIXTURE_DIR, 'avr-blink.ino.elf');
|
||||
|
||||
if (existsSync(hexPath)) avrHexData = new Uint8Array(readFileSync(hexPath));
|
||||
if (existsSync(binPath)) rp2040BinData = new Uint8Array(readFileSync(binPath));
|
||||
if (existsSync(elfPath)) avrElfData = new Uint8Array(readFileSync(elfPath));
|
||||
});
|
||||
|
||||
it('.hex file loaded for AVR board → returns HEX text', async () => {
|
||||
if (!avrHexData) return;
|
||||
const file = mockFile('blink.hex', avrHexData);
|
||||
const result = await readFirmwareFile(file, 'arduino-uno');
|
||||
expect(result.format).toBe('hex');
|
||||
expect(result.program).toContain(':');
|
||||
});
|
||||
|
||||
it('.bin file loaded for RP2040 board → returns base64', async () => {
|
||||
if (!rp2040BinData) return;
|
||||
const file = mockFile('blink.bin', rp2040BinData);
|
||||
const result = await readFirmwareFile(file, 'raspberry-pi-pico');
|
||||
expect(result.format).toBe('bin');
|
||||
// Valid base64
|
||||
expect(() => atob(result.program)).not.toThrow();
|
||||
});
|
||||
|
||||
it('.hex file loaded for ESP32-C3 → still works (HEX text passed through)', async () => {
|
||||
if (!avrHexData) return;
|
||||
const file = mockFile('blink.hex', avrHexData);
|
||||
const result = await readFirmwareFile(file, 'esp32-c3');
|
||||
// HEX is passed through as text regardless of board
|
||||
expect(result.format).toBe('hex');
|
||||
expect(result.program).toContain(':');
|
||||
});
|
||||
|
||||
it('AVR .elf loaded for ESP32 board → detects architecture mismatch', async () => {
|
||||
if (!avrElfData) return;
|
||||
const file = mockFile('blink.elf', avrElfData);
|
||||
const result = await readFirmwareFile(file, 'esp32');
|
||||
|
||||
// Should still load (no error), but elfInfo shows AVR
|
||||
expect(result.format).toBe('elf');
|
||||
expect(result.elfInfo!.architectureName).toBe('AVR');
|
||||
expect(result.elfInfo!.suggestedBoard).toBe('arduino-uno');
|
||||
// For ESP32 board, output should be base64 (not HEX)
|
||||
expect(result.program).not.toContain(':00000001FF');
|
||||
});
|
||||
|
||||
it('.bin file loaded for AVR board → returns base64 (user responsibility)', async () => {
|
||||
if (!rp2040BinData) return;
|
||||
const file = mockFile('firmware.bin', rp2040BinData);
|
||||
// Should not throw — it's the user's choice
|
||||
const result = await readFirmwareFile(file, 'arduino-uno');
|
||||
expect(result.format).toBe('bin');
|
||||
// Even though board is AVR, .bin returns base64 (compileBoardProgram will route it)
|
||||
expect(() => atob(result.program)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects files over 16MB', async () => {
|
||||
// Create a fake File that reports large size
|
||||
const largeFile = new File([new Uint8Array(1)], 'huge.bin');
|
||||
Object.defineProperty(largeFile, 'size', { value: 17 * 1024 * 1024 });
|
||||
|
||||
await expect(readFirmwareFile(largeFile, 'arduino-uno')).rejects.toThrow('too large');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// Minimal blink for AVR test fixture compilation
|
||||
void setup() {
|
||||
pinMode(LED_BUILTIN, OUTPUT);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
digitalWrite(LED_BUILTIN, HIGH);
|
||||
delay(500);
|
||||
digitalWrite(LED_BUILTIN, LOW);
|
||||
delay(500);
|
||||
}
|
||||
Binary file not shown.
|
|
@ -0,0 +1,59 @@
|
|||
:100000000C945C000C946E000C946E000C946E00CA
|
||||
:100010000C946E000C946E000C946E000C946E00A8
|
||||
:100020000C946E000C946E000C946E000C946E0098
|
||||
:100030000C946E000C946E000C946E000C946E0088
|
||||
:100040000C9413010C946E000C946E000C946E00D2
|
||||
:100050000C946E000C946E000C946E000C946E0068
|
||||
:100060000C946E000C946E00000000002400270029
|
||||
:100070002A0000000000250028002B0004040404CE
|
||||
:100080000404040402020202020203030303030342
|
||||
:10009000010204081020408001020408102001021F
|
||||
:1000A00004081020000000080002010000030407FB
|
||||
:1000B000000000000000000011241FBECFEFD8E0B8
|
||||
:1000C000DEBFCDBF21E0A0E0B1E001C01D92A930AC
|
||||
:1000D000B207E1F70E945D010C94CC010C94000082
|
||||
:1000E000E1EBF0E02491EDE9F0E09491E9E8F0E053
|
||||
:1000F000E491EE23C9F0222339F0233001F1A8F472
|
||||
:10010000213019F1223029F1F0E0EE0FFF1FEE58F7
|
||||
:10011000FF4FA591B4912FB7F894EC91811126C0AF
|
||||
:1001200090959E239C932FBF08952730A9F02830E7
|
||||
:10013000C9F0243049F7209180002F7D03C0209121
|
||||
:1001400080002F7720938000DFCF24B52F7724BD48
|
||||
:10015000DBCF24B52F7DFBCF2091B0002F772093EC
|
||||
:10016000B000D2CF2091B0002F7DF9CF9E2BDACFF7
|
||||
:100170003FB7F8948091050190910601A091070185
|
||||
:10018000B091080126B5A89B05C02F3F19F0019634
|
||||
:10019000A11DB11D3FBFBA2FA92F982F8827BC01E1
|
||||
:1001A000CD01620F711D811D911D42E0660F771F09
|
||||
:1001B000881F991F4A95D1F708958F929F92AF9209
|
||||
:1001C000BF92CF92DF92EF92FF920E94B8004B0154
|
||||
:1001D0005C0184EFC82EDD24D394E12CF12C0E9425
|
||||
:1001E000B800681979098A099B09683E734081053E
|
||||
:1001F0009105A8F321E0C21AD108E108F10888EEC0
|
||||
:10020000880E83E0981EA11CB11CC114D104E10426
|
||||
:10021000F10429F7FF90EF90DF90CF90BF90AF905F
|
||||
:100220009F908F9008951F920F920FB60F921124F6
|
||||
:100230002F933F938F939F93AF93BF93809101012F
|
||||
:1002400090910201A0910301B0910401309100014D
|
||||
:1002500023E0230F2D3758F50196A11DB11D2093E2
|
||||
:1002600000018093010190930201A0930301B093D8
|
||||
:1002700004018091050190910601A0910701B091C0
|
||||
:1002800008010196A11DB11D8093050190930601FF
|
||||
:10029000A0930701B0930801BF91AF919F918F91F7
|
||||
:1002A0003F912F910F900FBE0F901F90189526E849
|
||||
:1002B000230F0296A11DB11DD2CF789484B5826020
|
||||
:1002C00084BD84B5816084BD85B5826085BD85B5FA
|
||||
:1002D000816085BD80916E00816080936E00109278
|
||||
:1002E00081008091810082608093810080918100F3
|
||||
:1002F0008160809381008091800081608093800084
|
||||
:100300008091B10084608093B1008091B0008160E1
|
||||
:100310008093B00080917A00846080937A0080910D
|
||||
:100320007A00826080937A0080917A008160809365
|
||||
:100330007A0080917A00806880937A001092C100E0
|
||||
:10034000EDE9F0E02491E9E8F0E08491882399F068
|
||||
:1003500090E0880F991FFC01E859FF4FA591B491D7
|
||||
:10036000FC01EE58FF4F859194918FB7F894EC9172
|
||||
:10037000E22BEC938FBFC0E0D0E081E00E947000E0
|
||||
:100380000E94DD0080E00E9470000E94DD00209746
|
||||
:0C039000A1F30E940000F1CFF894FFCF11
|
||||
:00000001FF
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// Minimal blink for RP2040 test fixture compilation
|
||||
void setup() {
|
||||
pinMode(LED_BUILTIN, OUTPUT);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
digitalWrite(LED_BUILTIN, HIGH);
|
||||
delay(500);
|
||||
digitalWrite(LED_BUILTIN, LOW);
|
||||
delay(500);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue