Files
open-battery-information-web/firmware/uploader.js
T

492 lines
14 KiB
JavaScript
Raw Normal View History

2026-08-19 21:35:27 +03:00
/* 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();
})();