Initial commit
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
/* Firmware uploader — single-button wizard.
|
||||
Runs AVRDUDE compiled to WebAssembly (from the leaphy-robotics
|
||||
avrdude-webassembly package) and talks to the bootloader over Web Serial.
|
||||
|
||||
The wasm serial layer was rewritten (see avrdude.js glue) to talk to the
|
||||
port directly on the main thread — no worker, no SharedArrayBuffer, so no
|
||||
cross-origin isolation is required. */
|
||||
import avrdudeModule from "./avrdude.js?v=6";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const els = {
|
||||
flashBtn: $("flash-btn"),
|
||||
progressBar: $("progress-bar"),
|
||||
statusText: $("status-text"),
|
||||
spinner: $("spinner"),
|
||||
bundledSize: $("bundled-size"),
|
||||
hexFile: $("hex-file"),
|
||||
firmwareName: $("firmware-name"),
|
||||
logOutput: $("log-output"),
|
||||
openObiBtn: $("open-obi-btn"),
|
||||
boardRadios: document.querySelectorAll('input[name="board"]'),
|
||||
fwRadios: document.querySelectorAll('input[name="fw-source"]'),
|
||||
flowItems: { 1: $("flow-1"), 2: $("flow-2"), 3: $("flow-3") },
|
||||
};
|
||||
|
||||
const BOARD = {
|
||||
uno: {
|
||||
kind: "avrdude", part: "atmega328p", programmer: "arduino", baud: 115200,
|
||||
image: "uno.hex",
|
||||
note: "Uses firmware/uno.hex from this site, built automatically from the ArduinoOBI source.",
|
||||
},
|
||||
esp32c3: {
|
||||
kind: "esptool", image: "esp32.bin",
|
||||
note: "Uses firmware/esp32.bin (merged bootloader + partitions + app) built from the ArduinoOBI source for the ESP32-C3.",
|
||||
},
|
||||
};
|
||||
|
||||
function selectedBoard() {
|
||||
const checked = [...els.boardRadios].find((r) => r.checked);
|
||||
return checked ? checked.value : "uno";
|
||||
}
|
||||
|
||||
function updateFirmwareHint() {
|
||||
if (hexSource === "local") return;
|
||||
els.firmwareName.textContent = BOARD[selectedBoard()].note;
|
||||
}
|
||||
|
||||
let myPort = null; /* user-granted SerialPort (reused across runs) */
|
||||
let avrdudeModulePromise = null;
|
||||
let hexSource = "bundled";
|
||||
let bundledHexText = {};
|
||||
let avrdudeLogIndex = 0;
|
||||
let logPollTimer = null;
|
||||
|
||||
function appendLog(text, cls) {
|
||||
const span = document.createElement("span");
|
||||
if (cls) span.className = cls;
|
||||
span.textContent = text + "\n";
|
||||
els.logOutput.appendChild(span);
|
||||
els.logOutput.scrollTop = els.logOutput.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, kind) {
|
||||
els.statusText.textContent = text;
|
||||
els.statusText.className = "status-text" + (kind ? " " + kind : "");
|
||||
els.spinner.hidden = kind !== "busy";
|
||||
}
|
||||
|
||||
function setProgress(pct) {
|
||||
els.progressBar.style.width = pct + "%";
|
||||
}
|
||||
|
||||
function resetFlow() {
|
||||
for (const k in els.flowItems) {
|
||||
els.flowItems[k].classList.remove("active", "done");
|
||||
}
|
||||
els.flowItems[1].classList.add("active");
|
||||
}
|
||||
|
||||
function markFlow(n, state) {
|
||||
const el = els.flowItems[n];
|
||||
if (!el) return;
|
||||
el.classList.remove("active", "done");
|
||||
el.classList.add(state === "done" ? "done" : "active");
|
||||
}
|
||||
|
||||
function portInfo(port) {
|
||||
const info = port.getInfo ? port.getInfo() : {};
|
||||
const parts = [];
|
||||
if (info.usbVendorId) parts.push("VID " + info.usbVendorId.toString(16).padStart(4, "0"));
|
||||
if (info.usbProductId) parts.push("PID " + info.usbProductId.toString(16).padStart(4, "0"));
|
||||
return parts.length ? parts.join(" ") : "serial device";
|
||||
}
|
||||
|
||||
function hasWebSerial() {
|
||||
return "serial" in navigator;
|
||||
}
|
||||
|
||||
/* ---------- AVRDUDE wasm loading ---------- */
|
||||
function ensureAvrdude() {
|
||||
if (!avrdudeModulePromise) {
|
||||
appendLog("Loading AVRDUDE WebAssembly…", "l-info");
|
||||
avrdudeModulePromise = (async () => {
|
||||
const funcs = await avrdudeModule({
|
||||
print: (t) => appendLog(t, "l-info"),
|
||||
printErr: (t) => appendLog(t, "l-err"),
|
||||
});
|
||||
window.funcs = funcs;
|
||||
return funcs;
|
||||
})();
|
||||
avrdudeModulePromise.catch(() => {
|
||||
avrdudeModulePromise = null;
|
||||
});
|
||||
}
|
||||
return avrdudeModulePromise;
|
||||
}
|
||||
|
||||
function writeWasmFiles(funcs, confText, hexText) {
|
||||
try {
|
||||
funcs.FS.mkdir("/tmp");
|
||||
} catch (e) {
|
||||
/* already exists */
|
||||
}
|
||||
funcs.FS.writeFile("/tmp/avrdude.conf", confText);
|
||||
funcs.FS.writeFile("/tmp/firmware.hex", hexText);
|
||||
}
|
||||
|
||||
/* ---------- firmware source ---------- */
|
||||
async function getFirmware() {
|
||||
const board = BOARD[selectedBoard()];
|
||||
if (hexSource === "local") {
|
||||
const file = els.hexFile.files && els.hexFile.files[0];
|
||||
if (!file) {
|
||||
throw new Error("No local firmware file selected.");
|
||||
}
|
||||
if (board.kind === "esptool") {
|
||||
return { kind: "bin", data: new Uint8Array(await file.arrayBuffer()) };
|
||||
}
|
||||
return { kind: "hex", text: await file.text() };
|
||||
}
|
||||
if (board.kind === "esptool") {
|
||||
const res = await fetch("./esp32.bin");
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
"Could not fetch firmware/esp32.bin (" + res.status + "). " +
|
||||
"Run the firmware.yml GitHub Actions workflow to build it."
|
||||
);
|
||||
}
|
||||
return { kind: "bin", data: new Uint8Array(await res.arrayBuffer()) };
|
||||
}
|
||||
if (!bundledHexText[board.image]) {
|
||||
const res = await fetch("./" + board.image);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
"Could not fetch firmware/" + board.image + " (" + res.status + "). " +
|
||||
"Run the firmware.yml GitHub Actions workflow to build it."
|
||||
);
|
||||
}
|
||||
bundledHexText[board.image] = await res.text();
|
||||
}
|
||||
return { kind: "hex", text: bundledHexText[board.image] };
|
||||
}
|
||||
|
||||
async function loadBundledSize() {
|
||||
const board = BOARD[selectedBoard()];
|
||||
try {
|
||||
const res = await fetch("./" + board.image);
|
||||
if (res.ok) {
|
||||
const buf = await res.arrayBuffer();
|
||||
els.bundledSize.textContent = "(" + buf.byteLength.toLocaleString() + " bytes)";
|
||||
} else {
|
||||
els.bundledSize.textContent = "(missing — build with CI)";
|
||||
}
|
||||
} catch (e) {
|
||||
els.bundledSize.textContent = "(missing — build with CI)";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- avrdude log polling ---------- */
|
||||
function startLogPolling() {
|
||||
avrdudeLogIndex = 0;
|
||||
logPollTimer = setInterval(pollAvrdudeLog, 60);
|
||||
}
|
||||
|
||||
function stopLogPolling() {
|
||||
if (logPollTimer) {
|
||||
clearInterval(logPollTimer);
|
||||
logPollTimer = null;
|
||||
}
|
||||
pollAvrdudeLog();
|
||||
}
|
||||
|
||||
function pollAvrdudeLog() {
|
||||
const arr = window.avrdudeLog || [];
|
||||
while (avrdudeLogIndex < arr.length) {
|
||||
const line = arr[avrdudeLogIndex++];
|
||||
let text = line.replace(/\r/g, "\n");
|
||||
const m = text.match(/(\d+)%/);
|
||||
if (m) setProgress(parseInt(m[1], 10));
|
||||
for (const sub of text.split("\n")) {
|
||||
if (!sub) continue;
|
||||
const lower = sub.toLowerCase();
|
||||
if (lower.includes("error") || lower.includes("fail") || lower.includes("cannot")) {
|
||||
appendLog(sub, "l-err");
|
||||
} else if (lower.includes("done") || lower.includes("verified") || lower.includes("success")) {
|
||||
appendLog(sub, "l-ok");
|
||||
} else if (lower.includes("warning")) {
|
||||
appendLog(sub, "l-warn");
|
||||
} else {
|
||||
appendLog(sub, "l-info");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- port selection ---------- */
|
||||
async function selectPort() {
|
||||
if (!hasWebSerial()) {
|
||||
throw new Error(
|
||||
"Web Serial is not available in this browser. Use Chrome or Edge on desktop over HTTPS (or localhost)."
|
||||
);
|
||||
}
|
||||
let port;
|
||||
try {
|
||||
port = await navigator.serial.requestPort();
|
||||
} catch (e) {
|
||||
setStatus("Flash cancelled — no port selected.", "err");
|
||||
return null;
|
||||
}
|
||||
myPort = port;
|
||||
window.activePort = port;
|
||||
appendLog("Port selected: " + portInfo(port), "l-ok");
|
||||
return port;
|
||||
}
|
||||
|
||||
/* ---------- flash ---------- */
|
||||
async function flash() {
|
||||
els.flashBtn.disabled = true;
|
||||
els.openObiBtn.hidden = true;
|
||||
resetFlow();
|
||||
|
||||
try {
|
||||
if (!hasWebSerial()) {
|
||||
setStatus(
|
||||
"Web Serial isn't available in this browser. Use Chrome or Edge on desktop over HTTPS (or localhost).",
|
||||
"err"
|
||||
);
|
||||
appendLog("Web Serial is NOT available in this browser.", "l-err");
|
||||
return;
|
||||
}
|
||||
appendLog("Web Serial is available.", "l-ok");
|
||||
|
||||
/* navigator.serial.requestPort() requires a user gesture, so ask for
|
||||
the port first — before any await consumes transient activation. */
|
||||
if (!myPort) {
|
||||
setStatus("Plug in your board, then click to choose its port…", "busy");
|
||||
const port = await selectPort();
|
||||
if (!port) return;
|
||||
} else {
|
||||
window.activePort = myPort;
|
||||
}
|
||||
|
||||
markFlow(1, "done");
|
||||
markFlow(2, "active");
|
||||
|
||||
const board = BOARD[selectedBoard()];
|
||||
setStatus("Loading the flashing engine…", "busy");
|
||||
if (board.kind === "esptool") {
|
||||
await flashEsp32();
|
||||
} else {
|
||||
await flashAvrdude(board);
|
||||
}
|
||||
} catch (e) {
|
||||
stopLogPolling();
|
||||
setProgress(0);
|
||||
setStatus("Flash failed: " + e.message, "err");
|
||||
appendLog("Error: " + e.message, "l-err");
|
||||
/* The AVRDUDE wasm runtime may have aborted (e.g. exit() on a serial
|
||||
error). Reload it so the next attempt starts from a clean state. */
|
||||
if (avrdudeModulePromise) {
|
||||
avrdudeModulePromise = null;
|
||||
window.activePort = null;
|
||||
myPort = null;
|
||||
}
|
||||
/* Close the serial port so it stops reading and releases the connection;
|
||||
otherwise a stale port holds the Web Serial connection open and the
|
||||
next attempt fails or hangs. */
|
||||
closeActiveSerialPort();
|
||||
} finally {
|
||||
els.flashBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function flashAvrdude(board) {
|
||||
const funcs = await ensureAvrdude();
|
||||
|
||||
setStatus("Preparing the firmware image…", "busy");
|
||||
const firmware = await getFirmware();
|
||||
if (!firmware.text.trim()) throw new Error("The firmware hex file is empty.");
|
||||
const confRes = await fetch("./avrdude.conf");
|
||||
if (!confRes.ok) throw new Error("Could not fetch avrdude.conf (" + confRes.status + ").");
|
||||
const confText = await confRes.text();
|
||||
writeWasmFiles(funcs, confText, firmware.text);
|
||||
appendLog(
|
||||
"Firmware image: " + Math.round(firmware.text.trim().split("\n").length * 17 / 1024) +
|
||||
" KB of Intel HEX (" + firmware.text.trim().split("\n").length + " records).",
|
||||
"l-info"
|
||||
);
|
||||
|
||||
const args = [
|
||||
"avrdude",
|
||||
"-v",
|
||||
"-p", board.part,
|
||||
"-c", board.programmer,
|
||||
"-C", "/tmp/avrdude.conf",
|
||||
"-b", String(board.baud),
|
||||
"-D",
|
||||
"-P", "/dev/null",
|
||||
"-U", "flash:w:/tmp/firmware.hex:i",
|
||||
].join(" ");
|
||||
|
||||
setStatus("Flashing your board — don't unplug it…", "busy");
|
||||
window.avrdudeLog = [];
|
||||
startLogPolling();
|
||||
|
||||
const startAvrdude = funcs.cwrap("startAvrdude", "number", ["string"]);
|
||||
const rc = await withTimeout(startAvrdude(args), 120000);
|
||||
|
||||
stopLogPolling();
|
||||
|
||||
if (rc === 0) {
|
||||
setProgress(100);
|
||||
markFlow(2, "done");
|
||||
markFlow(3, "active");
|
||||
setStatus("Done! Your board is now running the OBI firmware.", "ok");
|
||||
appendLog("AVRDUDE exited with code 0. The board will now reboot into the OBI firmware.", "l-ok");
|
||||
els.openObiBtn.hidden = false;
|
||||
} else {
|
||||
setProgress(0);
|
||||
setStatus("Flash failed (exit code " + rc + "). Open Advanced for the log.", "err");
|
||||
appendLog("AVRDUDE exited with code " + rc + ". Check the log above.", "l-err");
|
||||
}
|
||||
}
|
||||
|
||||
async function flashEsp32() {
|
||||
const { ESPLoader, Transport } = await import("./esptool.js?v=6");
|
||||
appendLog("esptool.js loaded.", "l-ok");
|
||||
|
||||
setStatus("Preparing the firmware image…", "busy");
|
||||
const firmware = await getFirmware();
|
||||
appendLog("Firmware image: " + firmware.data.length.toLocaleString() + " bytes.", "l-info");
|
||||
|
||||
const terminal = {
|
||||
clean() {},
|
||||
write: (text) => appendLog(text, "l-info"),
|
||||
writeLine: (text) => appendLog(text, "l-info"),
|
||||
writeRaw: (text) => appendLog(text, "l-info"),
|
||||
};
|
||||
|
||||
const esp = new ESPLoader({
|
||||
transport: new Transport(myPort),
|
||||
baudrate: 115200,
|
||||
terminal,
|
||||
debugLogging: false,
|
||||
});
|
||||
let connected = false;
|
||||
try {
|
||||
setStatus("Connecting to the ESP32-C3 and entering download mode…", "busy");
|
||||
await esp.connect("default_reset", 7, true);
|
||||
connected = true;
|
||||
await esp.runStub();
|
||||
|
||||
setStatus("Flashing your board — don't unplug it…", "busy");
|
||||
await esp.writeFlash({
|
||||
fileArray: [{ data: firmware.data, address: 0x0 }],
|
||||
eraseAll: false,
|
||||
flashMode: "keep",
|
||||
flashFreq: "keep",
|
||||
flashSize: "keep",
|
||||
compress: true,
|
||||
reportProgress: (fileIndex, bytesSent, totalBytes) => {
|
||||
if (totalBytes > 0) setProgress(Math.round((bytesSent / totalBytes) * 100));
|
||||
},
|
||||
});
|
||||
setProgress(100);
|
||||
|
||||
setStatus("Restarting the board…", "busy");
|
||||
await esp.after("hard_reset");
|
||||
|
||||
markFlow(2, "done");
|
||||
markFlow(3, "active");
|
||||
setStatus("Done! Your ESP32-C3 is now running the OBI firmware.", "ok");
|
||||
appendLog("Flash complete. The board will now reboot into the OBI firmware.", "l-ok");
|
||||
els.openObiBtn.hidden = false;
|
||||
} finally {
|
||||
if (connected) {
|
||||
try {
|
||||
await esp.disconnect();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeActiveSerialPort() {
|
||||
const port = window.activePort;
|
||||
window.activePort = null;
|
||||
if (!port) return;
|
||||
try {
|
||||
if (window.__rxReader) {
|
||||
try {
|
||||
window.__rxReader.cancel().catch(() => {});
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
window.__rxReader = null;
|
||||
}
|
||||
if (window.__rxWriter) {
|
||||
try {
|
||||
window.__rxWriter.releaseLock();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
window.__rxWriter = null;
|
||||
}
|
||||
if (window.__rxQueue) window.__rxQueue.length = 0;
|
||||
if (port.readable || port.writable) port.close().catch(() => {});
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms) {
|
||||
return Promise.race([
|
||||
Promise.resolve(promise),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out after " + ms / 1000 + "s.")), ms)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/* ---------- wiring ---------- */
|
||||
els.flashBtn.addEventListener("click", flash);
|
||||
|
||||
function onBoardChange() {
|
||||
for (const o of els.boardRadios) {
|
||||
o.closest(".board-option").classList.toggle("selected", o.checked);
|
||||
}
|
||||
updateFirmwareHint();
|
||||
loadBundledSize();
|
||||
}
|
||||
|
||||
for (const option of els.boardRadios) {
|
||||
option.addEventListener("change", onBoardChange);
|
||||
option.closest(".board-option").addEventListener("click", onBoardChange);
|
||||
}
|
||||
|
||||
for (const radio of els.fwRadios) {
|
||||
radio.addEventListener("change", () => {
|
||||
hexSource = radio.value;
|
||||
els.hexFile.disabled = hexSource !== "local";
|
||||
if (hexSource === "bundled") {
|
||||
updateFirmwareHint();
|
||||
} else {
|
||||
els.firmwareName.textContent = "Select a firmware file compiled for the selected board.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
els.hexFile.addEventListener("change", () => {
|
||||
const f = els.hexFile.files && els.hexFile.files[0];
|
||||
els.firmwareName.textContent = f ? "Will flash " + f.name : "";
|
||||
});
|
||||
|
||||
/* ---------- init ---------- */
|
||||
(async function init() {
|
||||
appendLog("Uploader build v6 — direct Web Serial (no worker).", "l-ok");
|
||||
if (hasWebSerial()) {
|
||||
appendLog("Web Serial is available.", "l-ok");
|
||||
} else {
|
||||
els.flashBtn.disabled = true;
|
||||
setStatus(
|
||||
"Web Serial isn't available in this browser. Use Chrome or Edge on desktop over HTTPS (or localhost).",
|
||||
"err"
|
||||
);
|
||||
appendLog("Web Serial is NOT available in this browser.", "l-err");
|
||||
}
|
||||
loadBundledSize();
|
||||
})();
|
||||
Reference in New Issue
Block a user