594 lines
20 KiB
JavaScript
594 lines
20 KiB
JavaScript
import {
|
|
listSdFiles, readSdFile, writeSdFile, mkdirSd, deleteSdFile, clearSdFiles, readAllSdFiles,
|
|
} from './sdstore.js';
|
|
import { buildFat32Image } from './fat32.js';
|
|
|
|
const FLASH_SIZE = 16 * 1024 * 1024;
|
|
const BOOTLOADER_OFFSET = 0x0;
|
|
const PARTITIONS_OFFSET = 0x8000;
|
|
const APP_OFFSET = 0x10000;
|
|
const ESP_IMAGE_MAGIC = 0xe9;
|
|
const RELEASED_ADC = 4095;
|
|
|
|
export const BUTTONS = {
|
|
back: { type: 'adc', channel: 1, value: 3512 },
|
|
confirm: { type: 'adc', channel: 1, value: 2694 },
|
|
left: { type: 'adc', channel: 1, value: 1493 },
|
|
right: { type: 'adc', channel: 1, value: 5 },
|
|
up: { type: 'adc', channel: 2, value: 2242 },
|
|
down: { type: 'adc', channel: 2, value: 5 },
|
|
power: { type: 'gpio', pin: 3 },
|
|
};
|
|
|
|
// Assemble Flash Image - m25p80 requires a backing file exactly the chip size (16 MB), so a bare app image is placed into a 0xFF-erased 16 MB flash alongside the bootloader and partition table.
|
|
export function mergeFirmware(app, bootloader, partitions) {
|
|
if (app[0] !== ESP_IMAGE_MAGIC) {
|
|
throw new Error('Not an ESP32 app image (missing 0xE9 magic byte).');
|
|
}
|
|
if (APP_OFFSET + app.length > FLASH_SIZE) {
|
|
throw new Error(`App image is too large for the ${FLASH_SIZE / 1024 / 1024} MB flash.`);
|
|
}
|
|
const flash = new Uint8Array(FLASH_SIZE).fill(0xff);
|
|
flash.set(bootloader, BOOTLOADER_OFFSET);
|
|
flash.set(partitions, PARTITIONS_OFFSET);
|
|
flash.set(app, APP_OFFSET);
|
|
return flash;
|
|
}
|
|
|
|
async function fetchBin(url) {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Could not load ${url.pathname} (${response.status}).`);
|
|
}
|
|
return new Uint8Array(await response.arrayBuffer());
|
|
}
|
|
|
|
// A full 16 MB image is used as-is; anything else is treated as a CrossPoint app image and merged with the bundled bootloader and partition table.
|
|
async function prepareFirmware(bytes, assetRoot) {
|
|
if (bytes.length === FLASH_SIZE) {
|
|
return bytes;
|
|
}
|
|
const [bootloader, partitions] = await Promise.all([
|
|
fetchBin(new URL('assets/esp32c3-bootloader.bin', assetRoot)),
|
|
fetchBin(new URL('assets/crosspoint-partitions.bin', assetRoot)),
|
|
]);
|
|
return mergeFirmware(bytes, bootloader, partitions);
|
|
}
|
|
|
|
export function setButton(module, name, pressed) {
|
|
const button = BUTTONS[name];
|
|
if (!button) {
|
|
throw new Error(`Unknown button: ${name}`);
|
|
}
|
|
if (button.type === 'adc') {
|
|
module._xteink_wasm_set_adc(button.channel, pressed ? button.value : RELEASED_ADC);
|
|
} else {
|
|
module._xteink_wasm_set_gpio(button.pin, pressed ? 0 : 1);
|
|
}
|
|
}
|
|
|
|
function drawFrame(module, canvas) {
|
|
const width = module._xteink_wasm_width();
|
|
const height = module._xteink_wasm_height();
|
|
const stride = module._xteink_wasm_stride();
|
|
const pointer = module._xteink_wasm_framebuffer();
|
|
if (!width || !height || !stride || !pointer) {
|
|
return false;
|
|
}
|
|
|
|
if (canvas.width !== width || canvas.height !== height) {
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
}
|
|
|
|
const source = module.HEAPU8;
|
|
const image = new ImageData(width, height);
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const sourceOffset = pointer + y * stride + x * 4;
|
|
const targetOffset = (y * width + x) * 4;
|
|
image.data[targetOffset] = source[sourceOffset];
|
|
image.data[targetOffset + 1] = source[sourceOffset + 1];
|
|
image.data[targetOffset + 2] = source[sourceOffset + 2];
|
|
image.data[targetOffset + 3] = 255;
|
|
}
|
|
}
|
|
canvas.getContext('2d').putImageData(image, 0, 0);
|
|
return true;
|
|
}
|
|
|
|
function startDisplayLoop(module, canvas, setStatus, onReady) {
|
|
let generation = -1;
|
|
let powerHeld = false;
|
|
let heldFrames = 0;
|
|
let ready = false;
|
|
const update = () => {
|
|
const nextGeneration = module._xteink_wasm_frame_generation();
|
|
if (nextGeneration !== generation && drawFrame(module, canvas)) {
|
|
generation = nextGeneration;
|
|
const indicator = document.querySelector('#frame-generation');
|
|
indicator.value = String(generation);
|
|
document.dispatchEvent(new CustomEvent('xteink-frame', { detail: { generation } }));
|
|
if (!powerHeld) {
|
|
powerHeld = true;
|
|
setButton(module, 'power', true);
|
|
setStatus('Holding power for cold boot…');
|
|
} else if (!ready && ++heldFrames >= 2) {
|
|
setButton(module, 'power', false);
|
|
ready = true;
|
|
setStatus('Running.');
|
|
onReady();
|
|
}
|
|
}
|
|
requestAnimationFrame(update);
|
|
};
|
|
requestAnimationFrame(update);
|
|
}
|
|
|
|
function enableControls(module) {
|
|
for (const element of document.querySelectorAll('[data-button]')) {
|
|
element.disabled = false;
|
|
let pressed = false;
|
|
|
|
const press = () => {
|
|
if (!pressed) {
|
|
pressed = true;
|
|
element.classList.add('active');
|
|
setButton(module, element.dataset.button, true);
|
|
}
|
|
};
|
|
const release = () => {
|
|
if (pressed) {
|
|
pressed = false;
|
|
element.classList.remove('active');
|
|
setButton(module, element.dataset.button, false);
|
|
}
|
|
};
|
|
|
|
element.addEventListener('pointerdown', event => {
|
|
element.setPointerCapture(event.pointerId);
|
|
press();
|
|
});
|
|
element.addEventListener('pointerup', release);
|
|
element.addEventListener('pointercancel', release);
|
|
element.addEventListener('lostpointercapture', release);
|
|
element.addEventListener('keydown', event => {
|
|
if (!event.repeat && (event.key === ' ' || event.key === 'Enter')) {
|
|
event.preventDefault();
|
|
press();
|
|
}
|
|
});
|
|
element.addEventListener('keyup', event => {
|
|
if (event.key === ' ' || event.key === 'Enter') {
|
|
event.preventDefault();
|
|
release();
|
|
}
|
|
});
|
|
element.addEventListener('blur', release);
|
|
}
|
|
}
|
|
|
|
let logEl = null;
|
|
let persistLog = false;
|
|
const logBuffer = [];
|
|
const LOG_MAX_LINES = 1000;
|
|
const DEBUG_LOG_KEY = 'xteink-debug-log';
|
|
let activeModule = null;
|
|
if (typeof window !== 'undefined') {
|
|
window.__xteinkRuntimeErrors = [];
|
|
}
|
|
|
|
export function formatBytes(bytes) {
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`;
|
|
}
|
|
const units = ['KiB', 'MiB', 'GiB'];
|
|
let value = bytes;
|
|
let unit = -1;
|
|
do {
|
|
value /= 1024;
|
|
unit++;
|
|
} while (value >= 1024 && unit < units.length - 1);
|
|
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`;
|
|
}
|
|
|
|
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'json', 'cfg', 'csv', 'log', 'html', 'xml', 'ini', 'yml', 'yaml']);
|
|
let selectedSdPath = null;
|
|
let currentSdDirectory = '';
|
|
|
|
function isTextPath(path) {
|
|
return TEXT_EXTENSIONS.has(path.split('.').pop().toLowerCase());
|
|
}
|
|
|
|
function showEditor(show) {
|
|
document.querySelector('#files-browse').hidden = show;
|
|
document.querySelector('#files-edit').hidden = !show;
|
|
}
|
|
|
|
function closeEditor() {
|
|
selectedSdPath = null;
|
|
showEditor(false);
|
|
}
|
|
|
|
async function selectSdFile(path) {
|
|
selectedSdPath = path;
|
|
const editor = document.querySelector('#file-editor');
|
|
const save = document.querySelector('#file-save');
|
|
document.querySelector('#file-edit-name').textContent = path.split('/').pop();
|
|
try {
|
|
const bytes = await readSdFile(path);
|
|
if (isTextPath(path)) {
|
|
editor.value = new TextDecoder().decode(bytes);
|
|
editor.readOnly = false;
|
|
save.hidden = false;
|
|
} else {
|
|
editor.value = `(binary file — ${formatBytes(bytes.length)}; not editable here)`;
|
|
editor.readOnly = true;
|
|
save.hidden = true;
|
|
}
|
|
} catch (error) {
|
|
editor.value = `Could not read ${path}: ${error.message || error}`;
|
|
editor.readOnly = true;
|
|
save.hidden = true;
|
|
}
|
|
showEditor(true);
|
|
}
|
|
|
|
async function renderFiles() {
|
|
const list = document.querySelector('#file-list');
|
|
const entries = await listSdFiles();
|
|
if (currentSdDirectory && !entries.some(entry => entry.directory && entry.path === `${currentSdDirectory}/`)) {
|
|
currentSdDirectory = '';
|
|
}
|
|
|
|
const prefix = currentSdDirectory ? `${currentSdDirectory}/` : '';
|
|
const visibleEntries = entries.filter(entry => {
|
|
if (!entry.path.startsWith(prefix)) {
|
|
return false;
|
|
}
|
|
const relativePath = entry.path.slice(prefix.length).replace(/\/$/, '');
|
|
return relativePath && !relativePath.includes('/');
|
|
});
|
|
|
|
document.querySelector('#file-path').textContent = `/sdcard${currentSdDirectory ? `/${currentSdDirectory}` : ''}`;
|
|
document.querySelector('#file-back').disabled = !currentSdDirectory;
|
|
|
|
const body = list.querySelector('tbody');
|
|
body.replaceChildren();
|
|
|
|
if (!visibleEntries.length) {
|
|
const row = body.insertRow();
|
|
const cell = row.insertCell();
|
|
cell.colSpan = 4;
|
|
cell.className = 'file-empty';
|
|
cell.textContent = 'This folder is empty';
|
|
return;
|
|
}
|
|
|
|
const childCount = dirPrefix => entries.filter(e => {
|
|
const rest = e.path.slice(dirPrefix.length).replace(/\/$/, '');
|
|
return e.path.startsWith(dirPrefix) && rest && !rest.includes('/');
|
|
}).length;
|
|
|
|
for (const entry of visibleEntries) {
|
|
const name = entry.path.slice(prefix.length).replace(/\/$/, '');
|
|
const row = body.insertRow();
|
|
row.className = entry.directory ? 'directory' : 'file';
|
|
row.insertCell().textContent = entry.directory ? '\u{1F4C1}' : '\u{1F4C4}';
|
|
row.insertCell().textContent = name;
|
|
if (entry.directory) {
|
|
const n = childCount(entry.path);
|
|
row.insertCell().textContent = `${n} item${n === 1 ? '' : 's'}`;
|
|
} else {
|
|
row.insertCell().textContent = formatBytes(entry.size);
|
|
}
|
|
|
|
const del = document.createElement('button');
|
|
del.type = 'button';
|
|
del.className = 'file-del';
|
|
del.textContent = '\u2715';
|
|
del.title = `Delete ${name}`;
|
|
del.addEventListener('click', async event => {
|
|
event.stopPropagation();
|
|
if (!confirm(`Delete ${name}?`)) {
|
|
return;
|
|
}
|
|
await deleteSdFile(entry.path);
|
|
await renderFiles();
|
|
});
|
|
row.insertCell().append(del);
|
|
|
|
row.addEventListener('click', () => {
|
|
if (entry.directory) {
|
|
currentSdDirectory = entry.path.replace(/\/$/, '');
|
|
selectedSdPath = null;
|
|
renderFiles();
|
|
} else {
|
|
selectSdFile(entry.path);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function pathBytes(fs, path) {
|
|
try {
|
|
const stat = fs.stat(path);
|
|
if (!fs.isDir(stat.mode)) {
|
|
return { bytes: stat.size, files: 1 };
|
|
}
|
|
return fs.readdir(path)
|
|
.filter(name => name !== '.' && name !== '..')
|
|
.map(name => pathBytes(fs, `${path}/${name}`))
|
|
.reduce((total, item) => ({ bytes: total.bytes + item.bytes, files: total.files + item.files }), { bytes: 0, files: 0 });
|
|
} catch {
|
|
return { bytes: 0, files: 0 };
|
|
}
|
|
}
|
|
|
|
function renderDebug() {
|
|
const jsHeap = performance.memory?.usedJSHeapSize;
|
|
document.querySelector('#debug-js-heap').textContent = jsHeap ? formatBytes(jsHeap) : 'Unavailable in this browser';
|
|
if (!activeModule) {
|
|
return;
|
|
}
|
|
const visible = ['/flash.bin', '/bios', '/sd.img', '/var/tmp']
|
|
.map(path => pathBytes(activeModule.FS, path))
|
|
.reduce((total, item) => ({ bytes: total.bytes + item.bytes, files: total.files + item.files }), { bytes: 0, files: 0 });
|
|
document.querySelector('#debug-wasm-heap').textContent = formatBytes(activeModule.HEAPU8.byteLength);
|
|
document.querySelector('#debug-memfs').textContent = `${formatBytes(visible.bytes)} in ${visible.files} files`;
|
|
document.querySelector('#debug-sd-files').textContent = '64.0 MiB raw image (OPFS source)';
|
|
}
|
|
|
|
function initializeInspector() {
|
|
for (const tab of document.querySelectorAll('[role="tab"]')) {
|
|
tab.addEventListener('click', () => {
|
|
for (const candidate of document.querySelectorAll('[role="tab"]')) {
|
|
const selected = candidate === tab;
|
|
candidate.setAttribute('aria-selected', selected);
|
|
document.querySelector(`#${candidate.getAttribute('aria-controls')}`).hidden = !selected;
|
|
}
|
|
if (tab.dataset.tab === 'files') {
|
|
renderFiles();
|
|
} else if (tab.dataset.tab === 'debug') {
|
|
renderDebug();
|
|
}
|
|
});
|
|
}
|
|
wireFileControls();
|
|
renderFiles();
|
|
renderDebug();
|
|
setInterval(renderDebug, 1000);
|
|
}
|
|
|
|
function wireFileControls() {
|
|
const inDir = name => (currentSdDirectory ? `${currentSdDirectory}/${name}` : name);
|
|
document.querySelector('#file-add').addEventListener('change', async event => {
|
|
for (const file of event.target.files) {
|
|
await writeSdFile(inDir(file.name), new Uint8Array(await file.arrayBuffer()));
|
|
}
|
|
event.target.value = '';
|
|
await renderFiles();
|
|
});
|
|
document.querySelector('#file-new').addEventListener('click', async () => {
|
|
const name = prompt('New file name:')?.replace(/^\/+|\/+$/g, '');
|
|
if (!name) {
|
|
return;
|
|
}
|
|
const path = inDir(name);
|
|
await writeSdFile(path, '');
|
|
await renderFiles();
|
|
await selectSdFile(path);
|
|
});
|
|
document.querySelector('#file-new-folder').addEventListener('click', async () => {
|
|
const name = prompt('New folder name:')?.replace(/^\/+|\/+$/g, '');
|
|
if (!name) {
|
|
return;
|
|
}
|
|
await mkdirSd(inDir(name));
|
|
await renderFiles();
|
|
});
|
|
document.querySelector('#file-close').addEventListener('click', () => {
|
|
closeEditor();
|
|
renderFiles();
|
|
});
|
|
document.querySelector('#file-back').addEventListener('click', async () => {
|
|
currentSdDirectory = currentSdDirectory.split('/').slice(0, -1).join('/');
|
|
selectedSdPath = null;
|
|
await renderFiles();
|
|
});
|
|
document.querySelector('#file-reset').addEventListener('click', async () => {
|
|
if (!confirm('Delete all files from the SD card?')) {
|
|
return;
|
|
}
|
|
await clearSdFiles();
|
|
currentSdDirectory = '';
|
|
selectedSdPath = null;
|
|
await renderFiles();
|
|
});
|
|
document.querySelector('#file-save').addEventListener('click', async () => {
|
|
if (!selectedSdPath) {
|
|
return;
|
|
}
|
|
await writeSdFile(selectedSdPath, document.querySelector('#file-editor').value);
|
|
});
|
|
document.querySelector('#file-delete').addEventListener('click', async () => {
|
|
if (!selectedSdPath || !confirm(`Delete ${selectedSdPath}?`)) {
|
|
return;
|
|
}
|
|
await deleteSdFile(selectedSdPath);
|
|
closeEditor();
|
|
await renderFiles();
|
|
});
|
|
document.querySelector('#file-reboot').addEventListener('click', () => location.reload());
|
|
}
|
|
|
|
function logLine(message) {
|
|
logBuffer.push(message);
|
|
if (logBuffer.length > LOG_MAX_LINES) {
|
|
logBuffer.splice(0, logBuffer.length - LOG_MAX_LINES);
|
|
}
|
|
if (persistLog) {
|
|
sessionStorage.setItem(DEBUG_LOG_KEY, logBuffer.join('\n'));
|
|
}
|
|
if (logEl) {
|
|
const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4;
|
|
logEl.textContent = logBuffer.join('\n');
|
|
if (atBottom) {
|
|
logEl.scrollTop = logEl.scrollHeight;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function boot(file, variant, setStatus) {
|
|
if (!crossOriginIsolated) {
|
|
throw new Error('This emulator requires COOP/COEP headers. Start it with `make web`.');
|
|
}
|
|
setStatus('Loading firmware and ephemeral SD card…');
|
|
|
|
const artifactRoot = new URL('../dist/wasm/', import.meta.url);
|
|
const qemuUrl = new URL('qemu-system-riscv32.js', artifactRoot);
|
|
const [firmware, rom] = await Promise.all([
|
|
prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url),
|
|
fetchBin(new URL('esp32c3-rom.bin', artifactRoot)),
|
|
]);
|
|
const sdFiles = await readAllSdFiles();
|
|
const useVvfat = new URL(location.href).searchParams.has('vvfat');
|
|
setStatus(`${useVvfat ? 'Staging' : 'Building SD card image from'} ${sdFiles.length} browser file(s)…`);
|
|
const sdImage = useVvfat ? null : buildFat32Image(sdFiles);
|
|
const sdMessage = useVvfat
|
|
? `Using experimental vvfat backed by ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`
|
|
: `Using a 64 MiB FAT32 image built from ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`;
|
|
console.log(sdMessage);
|
|
logLine(sdMessage);
|
|
const { default: createQemu } = await import(qemuUrl);
|
|
|
|
const params = new URL(location.href).searchParams;
|
|
const qemuLogFlags = params.has('trace') ? 'guest_errors,unimp,in_asm' : 'guest_errors,unimp';
|
|
// icount throttles TCG ~4.7x here and starves the guest watchdog; off by default. ?icount restores it.
|
|
const icount = params.has('icount') ? ['-icount', 'shift=auto,align=off,sleep=off'] : [];
|
|
const options = {
|
|
arguments: [
|
|
'-machine', `xteink,variant=${variant}`,
|
|
'-accel', 'tcg,tb-size=16',
|
|
...icount,
|
|
'-L', '/bios',
|
|
'-display', 'none',
|
|
'-serial', 'stdio',
|
|
'-nic', 'none',
|
|
'-d', qemuLogFlags,
|
|
'-drive', 'file=/flash.bin,if=mtd,format=raw',
|
|
'-drive', useVvfat
|
|
? 'file=fat:16:rw:/sdcard,if=sd,format=raw'
|
|
: 'file=/sd.img,if=sd,format=raw',
|
|
],
|
|
locateFile: path => new URL(path, artifactRoot).href,
|
|
mainScriptUrlOrBlob: qemuUrl.href,
|
|
print: message => { console.log(message); logLine(message); },
|
|
printErr: message => { console.error(message); logLine(message); },
|
|
stdin: () => null,
|
|
};
|
|
options.preRun = [() => {
|
|
options.FS.mkdirTree('/bios');
|
|
options.FS.mkdirTree('/var/tmp');
|
|
options.FS.writeFile('/bios/esp32c3-rom.bin', rom);
|
|
options.FS.writeFile('/flash.bin', firmware);
|
|
if (useVvfat) {
|
|
for (const { path, bytes } of sdFiles) {
|
|
const slash = path.lastIndexOf('/');
|
|
const directory = slash < 0 ? '/sdcard' : `/sdcard/${path.slice(0, slash)}`;
|
|
options.FS.mkdirTree(directory);
|
|
options.FS.writeFile(`/sdcard/${path}`, bytes);
|
|
}
|
|
} else {
|
|
options.FS.writeFile('/sd.img', sdImage);
|
|
}
|
|
}];
|
|
|
|
setStatus('Starting emulator…');
|
|
return createQemu(options);
|
|
}
|
|
|
|
function initialize() {
|
|
const form = document.querySelector('#boot-form');
|
|
const firmwareInput = document.querySelector('#firmware');
|
|
const variantInput = document.querySelector('#variant');
|
|
const bootButton = document.querySelector('#boot');
|
|
const status = document.querySelector('#status');
|
|
const canvas = document.querySelector('#screen');
|
|
logEl = document.querySelector('#log');
|
|
const params = new URL(location.href).searchParams;
|
|
persistLog = params.has('firmware');
|
|
if (persistLog) {
|
|
logBuffer.push(...(sessionStorage.getItem(DEBUG_LOG_KEY) || '').split('\n').filter(Boolean));
|
|
logEl.textContent = logBuffer.join('\n');
|
|
}
|
|
document.querySelector('#log-clear').addEventListener('click', () => {
|
|
logBuffer.length = 0;
|
|
sessionStorage.removeItem(DEBUG_LOG_KEY);
|
|
logEl.textContent = '';
|
|
});
|
|
initializeInspector();
|
|
|
|
const setStatus = (message, error = false) => {
|
|
status.textContent = message;
|
|
status.classList.toggle('error', error);
|
|
};
|
|
const recordRuntimeError = value => {
|
|
const message = value instanceof Error ? value.stack || value.message : String(value);
|
|
window.__xteinkRuntimeErrors.push(message);
|
|
logLine(`[runtime] ${message}`);
|
|
setStatus(`Runtime error: ${message}`, true);
|
|
};
|
|
window.addEventListener('error', event => recordRuntimeError(event.message || event.error || 'Unknown worker error'));
|
|
window.addEventListener('unhandledrejection', event => recordRuntimeError(event.reason || 'Unhandled rejection'));
|
|
|
|
const start = async (file, variant = variantInput.value) => {
|
|
bootButton.disabled = true;
|
|
firmwareInput.disabled = true;
|
|
variantInput.disabled = true;
|
|
try {
|
|
const module = await boot(file, variant, setStatus);
|
|
activeModule = module;
|
|
renderFiles();
|
|
renderDebug();
|
|
if (persistLog) {
|
|
window.__xteinkModule = module;
|
|
}
|
|
startDisplayLoop(module, canvas, setStatus, () => enableControls(module));
|
|
} catch (error) {
|
|
console.error(error);
|
|
setStatus(error.message || String(error), true);
|
|
bootButton.disabled = false;
|
|
firmwareInput.disabled = false;
|
|
variantInput.disabled = false;
|
|
}
|
|
};
|
|
|
|
form.addEventListener('submit', async event => {
|
|
event.preventDefault();
|
|
const file = firmwareInput.files[0];
|
|
if (!file) {
|
|
setStatus('Choose a firmware image first.', true);
|
|
return;
|
|
}
|
|
start(file);
|
|
});
|
|
|
|
const firmwareUrl = params.get('firmware');
|
|
if (firmwareUrl) {
|
|
fetch(firmwareUrl)
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`Could not load debug firmware (${response.status}).`);
|
|
}
|
|
return response.blob();
|
|
})
|
|
.then(blob => start(new File([blob], 'firmware.bin'), params.get('variant') || 'x3'))
|
|
.catch(error => setStatus(error.message || String(error), true));
|
|
}
|
|
}
|
|
|
|
if (typeof document !== 'undefined') {
|
|
initialize();
|
|
}
|