From 894d8403f2b3f11f262fad3fcb4273b6f91fd0ba Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:50:33 -0500 Subject: [PATCH] Firmware page: Install from a file Pick a .bin or .uf2 you built and the page writes it the way it writes the release builds. A .bin gets a chip choice, preset from the image header, and the release manifests' offset: 0x2000 for a merged ESP32-P4 image, 0x0 for the S3 and for any whole-flash image. The page builds an ESP Web Tools manifest as a Blob URL whose part is a Blob URL of the file, and hands it to the vendored install button, which resolves blob: manifests and parts as they are. Chips outside BROWSER_INSTALL, and browsers without Web Serial, get the esptool steps with the file's name. An application image is refused, since it would boot-loop at the bootloader offset. A .uf2 gets the copy-to-drive steps. Both show the mpftp line for flashing from a terminal. --- firmware/index.html | 30 +++++++ firmware/page.css | 9 +- firmware/page.js | 200 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 225 insertions(+), 14 deletions(-) diff --git a/firmware/index.html b/firmware/index.html index 6f85566..1334d2f 100644 --- a/firmware/index.html +++ b/firmware/index.html @@ -57,6 +57,36 @@

Files

+
+

Install from a file

+ A .bin or .uf2 you built yourself +
+
+

Built your own firmware? Pick the file and this page writes it to your board, the same way as the builds above. The file stays on your computer.

+ + + + + + +
+

Desktop interpreters

Linux, Windows and the browser, with every PyDevices module diff --git a/firmware/page.css b/firmware/page.css index c6f5fc1..d50998b 100644 --- a/firmware/page.css +++ b/firmware/page.css @@ -21,9 +21,9 @@ .fw-steps { margin: 0; padding-left: 22px; color: var(--ink-soft); } .fw-steps li { margin: 0 0 14px; } .fw-steps li:last-child { margin-bottom: 0; } -.fw-steps pre { margin: 8px 0 0; padding: 12px 14px; overflow-x: auto; border: 1px solid var(--border-soft); +.fw-steps pre, .fw-cli pre { margin: 8px 0 0; padding: 12px 14px; overflow-x: auto; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--bg-soft); } -.fw-steps pre code, .fw-steps code { font-family: var(--mono); font-size: 0.88rem; } +.fw-steps pre code, .fw-steps code, .fw-cli pre code { font-family: var(--mono); font-size: 0.88rem; } .fw-scroll { overflow-x: auto; } .fw-table { width: 100%; border-collapse: collapse; font-size: 0.92rem; } .fw-table th, .fw-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border-soft); vertical-align: top; } @@ -35,3 +35,8 @@ .fw-pins summary { cursor: pointer; font-weight: 600; } .fw-pins table { margin-top: 12px; } #fw-install-button .btn { font: inherit; } +.fw h3 { margin: 22px 0 10px; font-size: 1rem; } +.fw-file-input { display: block; max-width: 100%; font: inherit; color: var(--ink); } +.fw-file-row { margin-top: 16px; } +#fw-file-install-button .btn { font: inherit; } +.fw-cli p { margin: 0; } diff --git a/firmware/page.js b/firmware/page.js index 0a5343b..e8a885d 100644 --- a/firmware/page.js +++ b/firmware/page.js @@ -11,6 +11,15 @@ // Chips the browser flasher (ESP Web Tools) can write. Set a chip to false to send its // boards to the esptool steps alone. var BROWSER_INSTALL = { esp32s3: true, esp32p4: true }; + // ESP Web Tools' names for the chips, as the release manifests use them. + var CHIP_FAMILY = { esp32s3: 'ESP32-S3', esp32p4: 'ESP32-P4', esp32: 'ESP32', esp32c3: 'ESP32-C3', + esp32c6: 'ESP32-C6', esp32s2: 'ESP32-S2' }; + // Where a merged image, which starts with the bootloader, is written on each chip. A file + // that does not start with an image header is a whole-flash image and goes at 0x0. + var BOOT_OFFSET = { esp32s3: '0x0', esp32p4: '0x2000', esp32: '0x1000', esp32c3: '0x0', + esp32c6: '0x0', esp32s2: '0x1000' }; + // The chip id an image header carries at byte 12 (esp_chip_id_t). + var CHIP_ID = { 0: 'esp32', 2: 'esp32s2', 5: 'esp32c3', 9: 'esp32s3', 13: 'esp32c6', 18: 'esp32p4' }; var ROLES = { flash: 'Flash image: the file to write', uf2: 'UF2: the file to copy', @@ -124,14 +133,7 @@ 'Chrome or Edge on a computer. Plug the board in, press Install, pick its serial port and confirm. ' + 'It asks whether to erase the board first: tick that when you come from another firmware. ' + 'Writing ' + mb(f.size) + ' takes a few minutes; leave the board plugged in until it says it is done.')); - var btn = document.createElement('esp-web-install-button'); - btn.setAttribute('manifest', 'manifests/' + b.id + '.json'); - btn.appendChild(h('button', { slot: 'activate', class: 'btn primary', type: 'button', text: 'Install ' + kind(b) })); - btn.appendChild(h('span', { slot: 'unsupported', class: 'fw-muted', - text: 'This browser can not write to a board. Use Chrome or Edge on a computer, or the esptool steps below.' })); - btn.appendChild(h('span', { slot: 'not-allowed', class: 'fw-muted', - text: 'Browsers only allow this on https pages or on localhost. Use the esptool steps below.' })); - holder.appendChild(btn); + holder.appendChild(installButton('manifests/' + b.id + '.json', 'Install ' + kind(b))); var hint = 'If the board does not appear in the list, hold BOOT while pressing RESET, then try again. '; if (b.chip === 'esp32p4') { hint += 'The browser flasher knows the ESP32-P4 but is newer on it than on other chips. If it stops partway, the esptool steps below write the same file.'; @@ -139,6 +141,24 @@ $('fw-install-hint').textContent = hint; } + function esptoolStep() { + return h('li', {}, 'Install esptool in a virtual environment and activate it, so the ', code('esptool'), + ' you run is the one you just installed. In each new terminal, activate it again before you run esptool.', + pre('# Windows\npython -m venv v\nv\\Scripts\\activate\npip install esptool\n\n' + + '# Linux and macOS\npython3 -m venv v\nsource v/bin/activate\npip install esptool')); + } + + function installButton(manifest, label) { + var btn = document.createElement('esp-web-install-button'); + btn.setAttribute('manifest', manifest); + btn.appendChild(h('button', { slot: 'activate', class: 'btn primary', type: 'button', text: label })); + btn.appendChild(h('span', { slot: 'unsupported', class: 'fw-muted', + text: 'This browser can not write to a board. Use Chrome or Edge on a computer, or the esptool steps below.' })); + btn.appendChild(h('span', { slot: 'not-allowed', class: 'fw-muted', + text: 'Browsers only allow this on https pages or on localhost. Use the esptool steps below.' })); + return btn; + } + function renderSteps(b) { var ol = clear($('fw-steps')); var f = mainFile(b); @@ -147,10 +167,7 @@ $('fw-steps-title').textContent = canBrowser(b) ? 'Or with esptool, in any browser' : 'Steps'; if (isEsp(b)) { - steps.push(h('li', {}, 'Install esptool in a virtual environment and activate it, so the ', code('esptool'), - ' you run is the one you just installed. In each new terminal, activate it again before you run esptool.', - pre('# Windows\npython -m venv v\nv\\Scripts\\activate\npip install esptool\n\n' + - '# Linux and macOS\npython3 -m venv v\nsource v/bin/activate\npip install esptool'))); + steps.push(esptoolStep()); steps.push(h('li', {}, link(local(f), 'Download ' + name), ' (' + mb(f.size) + '), then write it. ' + 'The port is COM3 or similar on Windows, /dev/ttyACM0 or /dev/ttyUSB0 on Linux, /dev/cu.usbmodem... on macOS. ' + 'On a board with two USB ports, use the one wired to the serial chip, usually marked UART. ', @@ -211,6 +228,163 @@ $('fw-verify').textContent = 'gh attestation verify ' + mainFile(b).name + ' --repo ' + REPO; } + // ---- install from a file ------------------------------------------------- + // The chosen file never leaves the computer: it becomes a Blob URL, and so does an + // ESP Web Tools manifest whose one part names it. ESP Web Tools fetches both the way it + // fetches a manifest and part from this site. + + var picked = null; // { file, uf2, head } + var pickSeq = 0; + var blobs = []; + + function readHead(file) { + return file.slice(0, 36).arrayBuffer().then(function (buf) { + var b = new Uint8Array(buf); + var image = b.length >= 14 && b[0] === 0xE9; + var id = image ? b[12] | (b[13] << 8) : null; + return { + image: image, + chip: image && CHIP_ID.hasOwnProperty(id) ? CHIP_ID[id] : '', + // An application image (micropython.bin) has its descriptor right after the headers. + app: image && b.length >= 36 && new DataView(buf).getUint32(32, true) === 0xABCD5432, + padded: b.length > 0 && b[0] === 0xFF + }; + }); + } + + function fileOffset(chip, head) { return head.image ? BOOT_OFFSET[chip] : '0x0'; } + function shellName(n) { return /^[\w.+-]+$/.test(n) ? n : '"' + n.replace(/(["\\$`])/g, '\\$1') + '"'; } + + function fileManifest(file, chip, offset) { + var part = URL.createObjectURL(file); + var m = { + name: file.name, + version: 'from a file', + new_install_prompt_erase: true, + new_install_improv_wait_time: 0, + builds: [{ chipFamily: CHIP_FAMILY[chip], parts: [{ path: part, offset: parseInt(offset, 16) }] }] + }; + var url = URL.createObjectURL(new Blob([JSON.stringify(m)], { type: 'application/json' })); + blobs.push(part, url); + return url; + } + + function fileNote(text) { + var n = $('fw-file-note'); + n.textContent = text || ''; + n.hidden = !text; + } + + function fileCli(text, line) { + var box = clear($('fw-file-mpftp')); + box.appendChild(h('p', {}, text)); + box.appendChild(pre(line)); + } + + function renderFile() { + blobs.forEach(function (u) { URL.revokeObjectURL(u); }); + blobs = []; + clear($('fw-file-install-button')); + $('fw-file-out').hidden = true; + $('fw-file-chip-row').hidden = true; + fileNote(''); + if (!picked) return; + var file = picked.file; + var name = file.name; + if (picked.uf2) { + renderFileUf2(name); + $('fw-file-out').hidden = false; + return; + } + var head = picked.head; + var chip = $('fw-file-chip').value; + if (head.app) { + fileNote(name + ' is an application image, not the merged firmware.bin. Written where the bootloader goes, ' + + 'it leaves the board restarting over and over. Pick the firmware.bin from the same build.'); + return; + } + if (head.image && !head.chip) { + fileNote(name + ' is for a chip this page does not list. mpftp and esptool can write it: both read the chip from the file.'); + return; + } + if (!head.image && !head.padded) { + fileNote(name + ' does not look like ESP32 firmware. Pick the firmware.bin from your build.'); + return; + } + $('fw-file-chip-row').hidden = false; + if (!chip) { fileNote('Choose the chip on your board.'); return; } + if (head.chip && head.chip !== chip) { + fileNote(name + ' was built for the ' + CHIP_FAMILY[head.chip] + ', not the ' + CHIP_FAMILY[chip] + + '. Choose the chip it was built for.'); + return; + } + var offset = fileOffset(chip, head); + var arg = shellName(name); + fileNote((head.chip ? 'The file says it is for the ' + CHIP_FAMILY[chip] + '. ' : '') + + 'It is written at ' + offset + '.'); + $('fw-file-out').hidden = false; + + var browser = BROWSER_INSTALL[chip] === true; + $('fw-file-install').hidden = !browser; + if (browser) { + $('fw-file-install-text').textContent = 'Chrome or Edge on a computer. Plug the board in, press Install, ' + + 'pick its serial port and confirm. It asks whether to erase the board first: tick that when you come from another firmware. ' + + 'Writing ' + mb(file.size) + ' takes a few minutes; leave the board plugged in until it says it is done.'; + $('fw-file-install-button').appendChild(installButton(fileManifest(file, chip, offset), 'Install this file')); + $('fw-file-install-hint').textContent = 'If the board does not appear in the list, hold BOOT while pressing RESET, then try again.' + + (chip === 'esp32p4' ? ' If it stops partway on the ESP32-P4, the esptool steps below write the same file.' : ''); + } + + $('fw-file-steps-title').textContent = browser ? 'Or with esptool, in any browser' : 'Steps'; + var ol = clear($('fw-file-steps')); + ol.appendChild(esptoolStep()); + ol.appendChild(h('li', {}, 'Write it, from the folder that holds ' + name + '. ' + + 'The port is COM3 or similar on Windows, /dev/ttyACM0 or /dev/ttyUSB0 on Linux, /dev/cu.usbmodem... on macOS. ' + + 'If esptool can not reach the board, put it in download mode: hold BOOT, press and release RESET, then release BOOT.', + pre('esptool --chip ' + chip + ' --port COM3 write-flash ' + offset + ' ' + arg))); + ol.appendChild(h('li', {}, 'Coming from another firmware, or want a clean board? Erase the flash first, then write as above (this removes your files):', + pre('esptool --chip ' + chip + ' --port COM3 erase-flash'))); + fileCli('mpftp writes the same file from a terminal, and reads the chip and the offset from the file itself:', + 'mpftp firmware flash --port esp32 --artifact ' + arg + ' -d COM3'); + } + + function renderFileUf2(name) { + $('fw-file-install').hidden = true; + $('fw-file-steps-title').textContent = 'Steps'; + var ol = clear($('fw-file-steps')); + ol.appendChild(h('li', {}, 'Put the board in its UF2 bootloader. On a Raspberry Pi Pico or another RP2040 or RP2350 board, ' + + 'hold BOOTSEL while you plug it into USB; on most other boards, double-tap RESET. ' + + 'A drive appears, named RPI-RP2, RP2350 or after the board.')); + ol.appendChild(h('li', {}, 'Copy ' + name + ' onto that drive. The board restarts by itself when the copy finishes.')); + fileCli('mpftp finds the bootloader drive and copies the file for you. Give your board\'s port: rp2, samd or esp32.', + 'mpftp firmware flash --port rp2 --uf2 --artifact ' + shellName(name)); + } + + function setupFile() { + var sel = $('fw-file-chip'); + sel.appendChild(h('option', { value: '', text: 'Choose the chip' })); + Object.keys(CHIP_FAMILY).forEach(function (c) { sel.appendChild(h('option', { value: c, text: CHIP_FAMILY[c] })); }); + sel.addEventListener('change', renderFile); + $('fw-file-input').addEventListener('change', function (e) { + var file = e.target.files && e.target.files[0]; + var seq = ++pickSeq; + picked = null; + if (!file) { renderFile(); return; } + if (/\.uf2$/i.test(file.name)) { picked = { file: file, uf2: true }; renderFile(); return; } + if (!/\.bin$/i.test(file.name)) { renderFile(); fileNote('Pick a .bin or a .uf2 file.'); return; } + renderFile(); + readHead(file).then(function (head) { + if (seq !== pickSeq) return; + picked = { file: file, uf2: false, head: head }; + // A file whose header names no chip gets no choice carried over from an earlier pick. + sel.value = head.chip || ''; + renderFile(); + }, function (err) { + if (seq === pickSeq) fileNote('Could not read ' + file.name + ' (' + err.message + ').'); + }); + }); + } + // ---- desktop interpreters ------------------------------------------------ function renderInterp() { @@ -245,6 +419,8 @@ $('fw-board').innerHTML = ''; } + setupFile(); + Promise.all([get('firmware.json'), get('interpreters.json', true)]).then(function (r) { doc = r[0]; interp = r[1];