diff --git a/dist/base_installer.js b/dist/base_installer.js new file mode 100644 index 0000000..84a2f13 --- /dev/null +++ b/dist/base_installer.js @@ -0,0 +1,465 @@ +// SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +'use strict'; +import {html, render} from 'https://unpkg.com/lit-html?module'; +import {asyncAppend} from 'https://unpkg.com/lit-html/directives/async-append?module'; +import * as esptoolPackage from "https://unpkg.com/esp-web-flasher@5.1.2/dist/web/index.js?module" + +// TODO: Figure out how to make the Web Serial from ESPTool and Web Serial to communicate with CircuitPython not conflict +// I think at the very least we'll have to reuse the same port so the user doesn't need to reselct, though it's possible it +// may change after reset. Since it's not +// +// For now, we'll use the following procedure for ESP32-S2 and ESP32-S3: +// 1. Install the bin file +// 2. Reset the board +// (if version 8.0.0-beta.6 or later) +// 3. Generate the settings.toml file +// 4. Write the settings.toml to the board via the REPL +// 5. Reset the board again +// +// For the esp32 and esp32c3, the procedure may be slightly different and going through the +// REPL may be required for the settings.toml file. +// 1. Install the bin file +// 2. Reset the board +// (if version 8.0.0-beta.6 or later) +// 3. Generate the settings.toml file +// 4. Write the settings.toml to the board via the REPL +// 5. Reset the board again +// +// To run REPL code, I may need to modularize the work I did for code.circuitpython.org +// That allows you to run code in the REPL and get the output back. I may end up creating a +// library that uses Web Serial and allows you to run code in the REPL and get the output back +// because it's very integrated into the serial recieve and send code. +// + +export const ESP_ROM_BAUD = 115200; + +export class InstallButton extends HTMLButtonElement { + static isSupported = 'serial' in navigator; + static isAllowed = window.isSecureContext; + + constructor() { + super(); + this.dialogElements = {}; + this.currentFlow = null; + this.currentStep = 0; + this.currentDialogElement = null; + this.port = null; + this.espStub = null; + this.dialogCssClass = "install-dialog"; + this.connected = this.connectionStates.DISCONNECTED; + this.menuTitle = "Installer Menu"; + } + + init() { + this.preloadDialogs(); + } + + // Define some common buttons + /* Buttons should have a label, and a callback and optionally a condition function on whether they should be enabled */ + previousButton = { + label: "Previous", + onClick: this.prevStep, + isEnabled: async () => { return this.currentStep > 0 }, + } + + nextButton = { + label: "Next", + onClick: this.nextStep, + isEnabled: async () => { return this.currentStep < this.currentFlow.steps.length - 1; }, + } + + closeButton = { + label: "Close", + onClick: async (e) => { + this.closeDialog(); + }, + } + + // Default Buttons + defaultButtons = [this.previousButton, this.nextButton]; + + // States and Button Labels + connectionStates = { + DISCONNECTED: "Connect", + CONNECTING: "Connecting...", + CONNECTED: "Disconnect", + } + + dialogs = { + notSupported: { + preload: false, + closeable: true, + template: (data) => html` + Sorry, Web Serial is not supported on your browser at this time. Browsers we expect to work: +
${this.menuTitle}
+ `, + buttons: [this.closeButton], + }, + }; + + flows = {}; + + baudRates = [ + 115200, + 128000, + 153600, + 230400, + 460800, + 921600, + 1500000, + 2000000, + ]; + + connectedCallback() { + if (InstallButton.isSupported && InstallButton.isAllowed) { + this.toggleAttribute("install-supported", true); + } else { + this.toggleAttribute("install-unsupported", true); + } + + this.addEventListener("click", async (e) => { + e.preventDefault(); + // WebSerial feature detection + if (!InstallButton.isSupported) { + await this.showNotSupported(); + } else { + await this.showMenu(); + } + }); + } + + // Parse out the url parameters from the current url + getUrlParams() { + // This should look for and validate very specific values + var hashParams = {}; + if (location.hash) { + location.hash.substr(1).split("&").forEach(function(item) {hashParams[item.split("=")[0]] = item.split("=")[1];}); + } + return hashParams; + } + + // Get a url parameter by name and optionally remove it from the current url in the process + getUrlParam(name) { + let urlParams = this.getUrlParams(); + let paramValue = null; + if (name in urlParams) { + paramValue = urlParams[name]; + } + + return paramValue; + } + + async enabledFlowCount() { + let enabledFlowCount = 0; + for (const [flowId, flow] of Object.entries(this.flows)) { + if (await flow.isEnabled()) { + enabledFlowCount++; + } + } + return enabledFlowCount; + } + + async * generateMenu(templateFunc) { + if (await this.enabledFlowCount() == 0) { + yield html`${this.menuTitle}
+ `,buttons:[this.closeButton]}};flows={};baudRates=[115200,128e3,153600,230400,460800,921600,15e5,2e6];connectedCallback(){InstallButton.isSupported&&InstallButton.isAllowed?this.toggleAttribute("install-supported",!0):this.toggleAttribute("install-unsupported",!0),this.addEventListener("click",async t=>{t.preventDefault(),InstallButton.isSupported?await this.showMenu():await this.showNotSupported()})}getUrlParams(){var e={};return location.hash&&location.hash.substr(1).split("&").forEach(function(t){e[t.split("=")[0]]=t.split("=")[1]}),e}getUrlParam(t){var e=this.getUrlParams();let s=null;return s=t in e?e[t]:s}async enabledFlowCount(){let t=0;for(var[e,s]of Object.entries(this.flows))await s.isEnabled()&&t++;return t}async*generateMenu(t){0==await this.enabledFlowCount()&&(yield html`+ Welcome to the CircuitPython Installer. This tool will install CircuitPython on your ${data.boardName}. +
++ This tool is new and experimental. If you experience any issues, feel free to check out + https://github.com/adafruit/circuitpython-org/issues + to see if somebody has already submitted the same issue you are experiencing. If not, feel free to open a new issue. If + you do see the same issue and are able to contribute additional information, that would be appreciated. +
++ If you are unable to use this tool, then the manual installation methods should still work. +
+ ` + }, + espSerialConnect: { + closeable: true, + template: (data) => html` ++ Make sure your board is plugged into this computer via a Serial connection using a USB Cable. +
++ + Click this button to open the Web Serial connection menu. +
+ +There may be many devices listed, such as your remembered Bluetooth peripherals, anything else plugged into USB, etc.
+ ++ If you aren't sure which to choose, look for words like "USB", "UART", "JTAG", and "Bridge Controller". There may be more than one right option depending on your system configuration. Experiment if needed. +
+ `, + buttons: [this.previousButton, { + label: "Next", + onClick: this.nextStep, + isEnabled: async () => { return (this.currentStep < this.currentFlow.steps.length - 1) && this.connected == this.connectionStates.CONNECTED }, + onUpdate: async (e) => { this.currentDialogElement.querySelector("#butConnect").innerText = this.connected; }, + }], + }, + confirm: { + template: (data) => html` +This will overwrite everything on the ${data.boardName}.
+ `, + buttons: [ + this.previousButton, + { + label: "Continue", + onClick: this.nextStep, + } + ], + }, + bootDriveSelect: { + closeable: true, + template: (data) => html` ++ Please select the ${data.drivename} Drive where the UF2 file will be copied. +
++ If you just installed the bootloader, you may need to reset your board. If you already had the bootloader installed, + you may need to double press the reset button. +
++ +
+ `, + buttons: [], + }, + circuitpyDriveSelect: { + closeable: true, + template: (data) => html` ++ Please select the CIRCUITPY Drive. If you don't see your CIRCUITPY drive, it may be disabled in boot.py or you may have renamed it at some point. +
++ +
+ `, + buttons: [], + }, + actionWaiting: { + template: (data) => html` +${data.action}...
+${data.action}...
+ + `, + buttons: [], + }, + cpSerial: { + closeable: true, + template: (data) => html` ++ The next step is to write your credentials to settings.toml. Make sure your board is running CircuitPython. If you just installed CircuitPython, you may to reset the board first. +
++ + Click this button to open the Web Serial connection menu. If it is already connected, pressing again will allow you to select a different port. +
+ +${data.serialPortInstructions}
+ `, + buttons: [this.previousButton, { + label: "Next", + onClick: this.nextStep, + isEnabled: async () => { return (this.currentStep < this.currentFlow.steps.length - 1) && !!this.replSerialDevice; }, + onUpdate: async (e) => { this.currentDialogElement.querySelector("#butConnect").innerText = !!this.replSerialDevice ? "Connected" : "Connect"; }, + }], + }, + + credentials: { + closeable: true, + template: (data) => html` + + `, + buttons: [this.previousButton, { + label: "Next", + onClick: this.saveCredentials, + }] + }, + success: { + closeable: true, + template: (data) => html` +Successfully Completed
+ ${data.ip ? + html`+ You can edit files by going to http://${data.ip}/code/. +
` : ''} + `, + buttons: [this.closeButton], + }, + error: { + closeable: true, + template: (data) => html` +Installation Error: ${data.message}
+ `, + buttons: [this.closeButton], + }, + } + + + ////////// STEP FUNCTIONS ////////// + + async stepWelcome() { + // Display Welcome Dialog + this.showDialog(this.dialogs.welcome, {boardName: this.boardName}); + } + + async stepSerialConnect() { + // Display Serial Connect Dialog + this.showDialog(this.dialogs.espSerialConnect); + } + + async stepConfirm() { + // Display Confirm Dialog + this.showDialog(this.dialogs.confirm, {boardName: this.boardName}); + } + + async stepEraseAll() { + // Display Erase Dialog + this.showDialog(this.dialogs.actionWaiting, { + action: "Erasing Flash", + }); + try { + await this.espStub.eraseFlash(); + } catch (err) { + this.errorMsg("Unable to finish erasing Flash memory. Please try again."); + } + await this.nextStep(); + } + + async stepFlashBin() { + if (!this.binFileUrl) { + // We shouldn't be able to get here, but just in case + this.errorMsg("Missing bin file URL. Please make sure the installer button has this specified."); + return; + } + + await this.downloadAndInstall(this.binFileUrl); + await this.espHardReset(); + await this.nextStep(); + } + + async stepBootloader() { + if (!this.bootloaderUrl) { + // We shouldn't be able to get here, but just in case + this.errorMsg("Missing bootloader file URL. Please make sure the installer button has this specified."); + return; + } + // Display Bootloader Dialog + await this.downloadAndInstall(this.bootloaderUrl, 'combined.bin', true); + await this.nextStep(); + } + + async stepSelectBootDrive() { + const bootloaderVolume = await this.getBootDriveName(); + + if (bootloaderVolume) { + this.logMsg(`Waiting for user to select a bootloader volume named ${bootloaderVolume}`); + } + + // Display Select Bootloader Drive Dialog + this.showDialog(this.dialogs.bootDriveSelect, { + drivename: bootloaderVolume ? bootloaderVolume : "Bootloader", + }); + } + + async stepSelectCpyDrive() { + this.logMsg(`Waiting for user to select CIRCUITPY drive`); + + // Display Select CIRCUITPY Drive Dialog + this.showDialog(this.dialogs.circuitpyDriveSelect); + } + + async stepCopyUf2() { + if (!this.bootDriveHandle) { + this.errorMsg("No boot drive selected. stepSelectBootDrive should preceed this step."); + return; + } + // Display Progress Dialog + this.showDialog(this.dialogs.actionProgress, { + action: `Copying ${this.uf2FileUrl}`, + }); + + // Do a copy and update progress along the way + await this.downloadAndCopy(this.uf2FileUrl); + + // Once done, call nextstep + await this.nextStep(); + } + + async stepSetupRepl() { + // TODO: Try and reuse the existing connection so user doesn't need to select it again + /*if (this.port) { + this.replSerialDevice = this.port; + await this.setupRepl(); + }*/ + const serialPortName = await this.getSerialPortName(); + let serialPortInstructions ="There may be several devices listed. If you aren't sure which to choose, look for one that includes the name of your microcontroller."; + if (serialPortName) { + serialPortInstructions =`There may be several devices listed, but look for one called something like ${serialPortName}.` + } + this.showDialog(this.dialogs.cpSerial, { + serialPortInstructions: serialPortInstructions + }); + } + + async stepCredentials() { + // We may want to see if the board has previously been set up and fill in any values from settings.toml and boot.py + this.tomlSettings = await this.getCurrentSettings(); + console.log(this.tomlSettings); + const parameters = { + wifi_ssid: this.getSetting('CIRCUITPY_WIFI_SSID'), + wifi_password: this.getSetting('CIRCUITPY_WIFI_PASSWORD'), + api_password: this.getSetting('CIRCUITPY_WEB_API_PASSWORD', 'passw0rd'), + api_port: this.getSetting('CIRCUITPY_WEB_API_PORT', 80), + } + + if (this.hasNativeUsb()) { + // TODO: Currently the control is just disabled and not used because we don't have anything to modify boot.py in place. + // Setting mass_storage_disabled to true/false will display the checkbox with the appropriately checked state. + //parameters.mass_storage_disabled = true; + } + + // Display Credentials Request Dialog + this.showDialog(this.dialogs.credentials, parameters); + } + + async stepSuccess() { + let deviceHostInfo = {}; + if (this.repl) { + await this.repl.waitForPrompt(); + // If we were setting up Web Workflow, we may want to provide a link to code.circuitpython.org + if (this.currentFlow || this.currentFlow.steps.includes(this.stepCredentials)) { + deviceHostInfo = await this.getDeviceHostInfo(); + } + } + + // Display Success Dialog + this.showDialog(this.dialogs.success, deviceHostInfo); + } + + async stepClose() { + // Close the currently loaded dialog + this.closeDialog(); + } + + ////////// HANDLERS ////////// + + async bootDriveSelectHandler(e) { + const bootloaderVolume = await this.getBootDriveName(); + let dirHandle; + + // This will need to show a dialog selector + try { + dirHandle = await window.showDirectoryPicker({mode: 'readwrite'}); + } catch (e) { + // Likely the user cancelled the dialog + return; + } + if (bootloaderVolume && bootloaderVolume != dirHandle.name) { + alert(`The selected drive named ${dirHandle.name} does not match the expected name of ${bootloaderVolume}. Please select the correct drive.`); + return; + } + if (!await this._verifyPermission(dirHandle)) { + alert("Unable to write to the selected folder"); + return; + } + + this.bootDriveHandle = dirHandle; + await this.nextStep(); + } + + async circuitpyDriveSelectHandler(e) { + let dirHandle; + + // This will need to show a dialog selector + try { + dirHandle = await window.showDirectoryPicker({mode: 'readwrite'}); + } catch (e) { + // Likely the user cancelled the dialog + return; + } + // Check if boot_out.txt exists + if (!(await this.getBootOut(dirHandle))) { + alert(`Expecting a folder with boot_out.txt. Please select the root folder of your CIRCUITPY drive.`); + return; + } + if (!await this._verifyPermission(dirHandle)) { + alert("Unable to write to the selected folder"); + return; + } + + this.circuitpyDriveHandle = dirHandle; + await this.nextStep(); + } + + async espToolConnectHandler(e) { + await this.onReplDisconnected(e); + await this.espDisconnect(); + let esploader; + try { + esploader = await this.espConnect({ + log: (...args) => this.logMsg(...args), + debug: (...args) => {}, + error: (...args) => this.errorMsg(...args), + }); + } catch (err) { + // It's possible the dialog was also canceled here + this.errorMsg("Unable to open Serial connection to board. Make sure the port is not already in use by another application or in another browser tab."); + return; + } + + try { + this.updateEspConnected(this.connectionStates.CONNECTING); + await esploader.initialize(); + this.updateEspConnected(this.connectionStates.CONNECTED); + } catch (err) { + await esploader.disconnect(); + // Disconnection before complete + this.updateEspConnected(this.connectionStates.DISCONNECTED); + this.errorMsg("Unable to connect to the board. Make sure it is in bootloader mode by holding the boot0 button when powering on and try again.") + return; + } + + try { + this.logMsg(`Connected to ${esploader.chipName}`); + this.logMsg(`MAC Address: ${this.formatMacAddr(esploader.macAddr())}`); + + // check chip compatibility + if (FAMILY_TO_CHIP_MAP[this.chipFamily] == esploader.chipFamily) { + this.logMsg("This chip checks out"); + this.espStub = await esploader.runStub(); + this.espStub.addEventListener("disconnect", () => { + this.updateEspConnected(this.connectionStates.DISCONNECTED); + this.espStub = null; + }); + + await this.setBaudRateIfChipSupports(esploader.chipFamily, PREFERRED_BAUDRATE); + await this.nextStep(); + return; + } + + // Can't use it so disconnect now + this.errorMsg("Oops, this is the wrong firmware for your board.") + await this.espDisconnect(); + + } catch (err) { + await esploader.disconnect(); + // Disconnection before complete + this.updateEspConnected(this.connectionStates.DISCONNECTED); + this.errorMsg("Oops, we lost connection to your board before completing the install. Please check your USB connection and click Connect again. Refresh the browser if it becomes unresponsive.") + } + } + + async onSerialReceive(e) { + await this.repl.onSerialReceive(e); + } + + async cpSerialConnectHandler(e) { + // Disconnect from the ESP Tool if Connected + await this.espDisconnect(); + + await this.onReplDisconnected(e); + + // Connect to the Serial Port and interact with the REPL + try { + this.replSerialDevice = await navigator.serial.requestPort(); + } catch (e) { + // Likely the user cancelled the dialog + return; + } + + try { + await this.replSerialDevice.open({baudRate: ESP_ROM_BAUD}); + } catch (e) { + console.error("Error. Unable to open Serial Port. Make sure it isn't already in use in another tab or application."); + } + + await this.setupRepl(); + + this.nextStep(); + } + + async setupRepl() { + if (this.replSerialDevice) { + this.repl = new REPL(); + this.repl.serialTransmit = this.serialTransmit.bind(this); + + this.replSerialDevice.addEventListener("message", this.onSerialReceive.bind(this)); + + // Start the read loop + this._readLoopPromise = this._readSerialLoop().catch( + async function(error) { + await this.onReplDisconnected(); + }.bind(this) + ); + + if (this.replSerialDevice.writable) { + this.writer = this.replSerialDevice.writable.getWriter(); + await this.writer.ready; + } + } + } + + async onReplDisconnected(e) { + if (this.reader) { + try { + await this.reader.cancel(); + } catch(e) { + // Ignore + } + this.reader = null; + } + if (this.writer) { + await this.writer.releaseLock(); + this.writer = null; + } + + if (this.replSerialDevice) { + try { + await this.replSerialDevice.close(); + } catch(e) { + // Ignore + } + this.replSerialDevice = null; + } + } + + //////////////// FILE HELPERS //////////////// + + async getBootDriveName() { + if (this._bootDriveName) { + return this._bootDriveName; + } + await this.extractBootloaderInfo(); + + return this._bootDriveName; + } + + async getSerialPortName() { + if (this._serialPortName) { + return this._serialPortName; + } + await this.extractBootloaderInfo(); + + return this._serialPortName; + } + + async _verifyPermission(folderHandle) { + const options = {mode: 'readwrite'}; + + if (await folderHandle.queryPermission(options) === 'granted') { + return true; + } + + if (await folderHandle.requestPermission(options) === 'granted') { + return true; + } + + return false; + } + + async extractBootloaderInfo() { + if (!this.bootloaderUrl) { + return false; + } + + // Download the bootloader zip file + let [filename, fileBlob] = await this.downloadAndExtract(this.bootloaderUrl, 'tinyuf2.bin'); + const fileContents = await fileBlob.text(); + + const bootDriveRegex = /B\x00B\x00([A-Z0-9\x00]{11})FAT16/; + const serialNameRegex = /0123456789ABCDEF(.+)\x00UF2/; + // Not sure if manufacturer is displayed. If not, we should use this instead + // const serialNameRegex = /0123456789ABCDEF(?:.*\x00)?(.+)\x00UF2/; + + let matches = fileContents.match(bootDriveRegex); + if (matches && matches.length >= 2) { + // Strip any null characters from the name + this._bootDriveName = matches[1].replace(/\0/g, ''); + } + + matches = fileContents.match(serialNameRegex); + if (matches && matches.length >= 2) { + // Replace any null characters with spaces + this._serialPortName = matches[1].replace(/\0/g, ' '); + } + + this.removeCachedFile(this.bootloaderUrl.split("/").pop()); + } + + async getBootOut(dirHandle) { + return await this.readFile("boot_out.txt", dirHandle); + } + + async readFile(filename, dirHandle = null) { + // Read a file from the given directory handle + + if (!dirHandle) { + dirHandle = this.circuitpyDriveHandle; + } + if (!dirHandle) { + console.warn("CIRCUITPY Drive not selected and no Directory Handle provided"); + return null; + } + try { + const fileHandle = await dirHandle.getFileHandle(filename); + const fileData = await fileHandle.getFile(); + + return await fileData.text(); + } catch (e) { + return null; + } + } + + async writeFile(filename, contents, dirHandle = null) { + // Write a file to the given directory handle + if (!dirHandle) { + dirHandle = this.circuitpyDriveHandle; + } + if (!dirHandle) { + console.warn("CIRCUITPY Drive not selected and no Directory Handle provided"); + return null; + } + + const fileHandle = await dirHandle.getFileHandle(filename, {create: true}); + const writable = await fileHandle.createWritable(); + await writable.write(contents); + await writable.close(); + } + + + //////////////// DOWNLOAD HELPERS //////////////// + + addCachedFile(filename, blob) { + this.fileCache.push({ + filename: filename, + blob: blob + }); + } + + getCachedFile(filename) { + for (let file of this.fileCache) { + if (file.filename === filename) { + return file.contents; + } + } + return null; + } + + removeCachedFile(filename) { + for (let file of this.fileCache) { + if (file.filename === filename) { + this.fileCache.splice(this.fileCache.indexOf(file), 1); + } + } + } + + async downloadFile(url, progressElement) { + let response; + try { + response = await fetch(url); + } catch (err) { + this.errorMsg(`Unable to download file: ${url}`); + return null; + } + + const body = response.body; + const reader = body.getReader(); + const contentLength = +response.headers.get('Content-Length'); + let receivedLength = 0; + let chunks = []; + while(true) { + const {done, value} = await reader.read(); + if (done) { + break; + } + chunks.push(value); + receivedLength += value.length; + progressElement.value = Math.round((receivedLength / contentLength) * 100); + this.logMsg(`Received ${receivedLength} of ${contentLength}`) + } + let chunksAll = new Uint8Array(receivedLength); + let position = 0; + for(let chunk of chunks) { + chunksAll.set(chunk, position); + position += chunk.length; + } + + let result = new Blob([chunksAll]); + + return result; + } + + async downloadAndExtract(url, fileToExtract = null, cacheFile = false) { + // Display Progress Dialog + let filename = url.split("/").pop(); + let fileBlob = this.getCachedFile(filename); + + if (!fileBlob) { + this.showDialog(this.dialogs.actionProgress, { + action: `Downloading ${filename}` + }); + + const progressElement = this.currentDialogElement.querySelector("#stepProgress"); + + // Download the file at the url updating the progress in the process + fileBlob = await this.downloadFile(url, progressElement); + + if (cacheFile) { + this.addCachedFile(filename, fileBlob); + } + } + + // If the file is a zip file, unzip and find the file to extract + if (filename.endsWith(".zip") && fileToExtract) { + let foundFile; + // Update the Progress dialog + this.showDialog(this.dialogs.actionProgress, { + action: `Extracting ${fileToExtract}` + }); + + // Set that to the current file to flash + [foundFile, fileBlob] = await this.findAndExtractFromZip(fileBlob, fileToExtract); + if (!fileBlob) { + this.errorMsg(`Unable to find ${fileToExtract} in ${filename}`); + return; + } + filename = foundFile; + } + + return [filename, fileBlob]; + } + + async downloadAndInstall(url, fileToExtract = null, cacheFile = false) { + let [filename, fileBlob] = await this.downloadAndExtract(url, fileToExtract, cacheFile); + + // Update the Progress dialog + if (fileBlob) { + const fileContents = (new Uint8Array(await fileBlob.arrayBuffer())).buffer; + let lastPercent = 0; + this.showDialog(this.dialogs.actionProgress, { + action: `Flashing ${filename}` + }); + + const progressElement = this.currentDialogElement.querySelector("#stepProgress"); + progressElement.value = 0; + + try { + await this.espStub.flashData(fileContents, (bytesWritten, totalBytes) => { + let percentage = Math.round((bytesWritten / totalBytes) * 100); + if (percentage > lastPercent) { + progressElement.value = percentage; + this.logMsg(`${percentage}% (${bytesWritten}/${totalBytes})...`); + lastPercent = percentage; + } + }, 0, 0); + } catch (err) { + this.errorMsg(`Unable to flash file: ${filename}. Error Message: ${err}`); + } + } + } + + async downloadAndCopy(url, dirHandle = null) { + if (!dirHandle) { + dirHandle = this.bootDriveHandle; + } + if (!dirHandle) { + this.errorMsg("No drive handle available"); + return; + } + + const progressElement = this.currentDialogElement.querySelector("#stepProgress"); + progressElement.value = 0; + + let [filename, fileBlob] = await this.downloadAndExtract(url); + const fileHandle = await dirHandle.getFileHandle(filename, {create: true}); + const writableStream = await fileHandle.createWritable(); + const totalSize = fileBlob.size; + let bytesWritten = 0; + let chunk; + while(bytesWritten < totalSize) { + chunk = fileBlob.slice(bytesWritten, bytesWritten + COPY_CHUNK_SIZE); + await writableStream.write(chunk, {position: bytesWritten, size: chunk.size}); + + bytesWritten += chunk.size; + progressElement.value = Math.round(bytesWritten / totalSize * 100); + this.logMsg(`${Math.round(bytesWritten / totalSize * 100)}% (${bytesWritten} / ${totalSize}) written...`); + } + this.logMsg("File successfully written"); + try { + // Attempt to close the file, but since the device reboots, it may error + await writableStream.close(); + this.logMsg("File successfully closed"); + } catch (err) { + this.logMsg("Error closing file, probably due to board reset. Continuing..."); + } + } + + async findAndExtractFromZip(zipBlob, filename) { + const reader = new zip.ZipReader(new zip.BlobReader(zipBlob)); + + // unzip into local file cache + let zipContents = await reader.getEntries(); + + for(const zipEntry of zipContents) { + if (zipEntry.filename.localeCompare(filename) === 0) { + const extractedFile = await zipEntry.getData(new zip.BlobWriter()); + return [zipEntry.filename, extractedFile]; + } + } + + return [null, null]; + } + + + //////////////// OTHER HELPERS //////////////// + + async saveCredentials() { + this.saveSetting('CIRCUITPY_WIFI_SSID'); + this.saveSetting('CIRCUITPY_WIFI_PASSWORD'); + this.saveSetting('CIRCUITPY_WEB_API_PASSWORD'); + this.saveSetting('CIRCUITPY_WEB_API_PORT'); + + await this.writeSettings(this.tomlSettings); + if (this.hasNativeUsb()) { + //this.setBootDisabled(true); + } + await this.nextStep(); + } + + getSetting(setting, defaultValue = '') { + if (this.tomlSettings && this.tomlSettings.hasOwnProperty(setting)) { + return this.tomlSettings[setting]; + } + + return defaultValue; + } + + async getBootDisabled() { + // This is a very simple check for now. If there is something more complicated like a disable + // command behind an if statement, this will not detect it is enabled. + let fileContents; + if (this.repl) { + return true; // Always disabled in this case + } else if (this.circuitpyDriveHandle) { + fileContents = await this.readFile("boot.py"); + // TODO: Compare board's boot.py to our boot.py by + // searching for storage.disable_usb_drive() at the beginning of the line + } else { + this.errorMsg("Connect to the CIRCUITPY drive or the REPL first"); + return {}; + } + + if (fileContents) { + return toml.parse(fileContents); + } + this.logMsg("Unable to read settings.toml from CircuitPython. It may not exist. Continuing..."); + return {}; + } + + saveBootDisabled(disabled) { + // TODO: Save/remove a copy of boot.py on the CIRCUITPY drive + // This depends on whether it is currently disabled in boot.py and what the value of disabled is + // If they are the same, we can skip + // An idea is to only deal with this if boot.py doesn't exist and just use a generic boot.py + // For disabling, we can compare to the generic and if they are different refuse to touch it + const formElement = this.currentDialogElement.querySelector('#circuitpy_drive'); + if (formElement) { + if (formElement.checked) { + this.tomlSettings['CIRCUITPY_DRIVE'] = "disabled"; + } else { + this.tomlSettings['CIRCUITPY_DRIVE'] = "enabled"; + } + } + + } + + saveSetting(settingName) { + const formElement = this.currentDialogElement.querySelector(`#${settingName.toLowerCase()}`) + if (formElement) { + if (formElement.type == "number") { + this.tomlSettings[settingName] = parseInt(formElement.value); + } else if (formElement.type == "text" || formElement.type == "password") { + this.tomlSettings[settingName] = formElement.value; + } else { + this.errorMsg(`A setting was found, but a form element of type ${formElement.type} was not expected.`); + } + } else { + this.errorMsg(`A setting named '${settingName}' was not found.`); + } + } + + async runCode(code, outputToConsole = true) { + if (Array.isArray(code)) { + code = code.join("\n"); + } + + if (this.repl) { + const output = await this.repl.runCode(code); + + if (outputToConsole) { + console.log(output); + } + } + } + + async writeSettings(settings) { + if (this.repl) { + await this.runCode(`import storage`); + await this.runCode(`storage.remount("/", False)`); + await this.runCode(`f = open('settings.toml', 'w')`); + + for (const [setting, value] of Object.entries(settings)) { + if (typeof value === "string") { + await this.runCode(`f.write('${setting} = "${value}"\\n')`); + } else { + await this.runCode(`f.write('${setting} = ${value}\\n')`); + } + } + await this.runCode(`f.close()`); + + // Perform a soft restart to avoid losing the connection and get an IP address + this.showDialog(this.dialogs.actionWaiting, { + action: "Waiting for IP Address...", + }); + await this.repl.softRestart(); + try { + await this.timeout( + async () => { + let deviceInfo = {}; + while (Object.entries(deviceInfo).length == 0 || deviceInfo.ip === null) { + deviceInfo = await this.getDeviceHostInfo(); + await this.sleep(300); + } + }, 10000 + ); + } catch (error) { + console.warn("Unable to get IP Address. Network Credentials may be incorrect"); + return null; + } + } else if (this.circuitpyDriveHandle) { + const contents = toml.stringify(settings); + await this.writeFile("settings.toml", contents); + } else { + this.errorMsg("Connect to the CIRCUITPY drive or the REPL first"); + return null; + } + } + + async getCurrentSettings() { + let fileContents; + if (this.repl) { + fileContents = await this.runCode(["f = open('settings.toml', 'r')", "print(f.read())", "f.close()"]); + } else if (this.circuitpyDriveHandle) { + fileContents = await this.readFile("settings.toml"); + } else { + this.errorMsg("Connect to the CIRCUITPY drive or the REPL first"); + return {}; + } + + if (fileContents) { + return toml.parse(fileContents); + } + this.logMsg("Unable to read settings.toml from CircuitPython. It may not exist. Continuing..."); + return {}; + } + + async espDisconnect() { + // Disconnect the ESPTool + if (this.espStub) { + await this.espStub.disconnect(); + this.espStub.removeEventListener("disconnect", this.espDisconnect.bind(this)); + this.updateEspConnected(this.connectionStates.DISCONNECTED); + this.espStub = null; + } + if (this.port) { + await this.port.close(); + this.port = null; + } + } + + async serialTransmit(msg) { + const encoder = new TextEncoder(); + if (this.writer) { + const encMessage = encoder.encode(msg); + await this.writer.ready.catch((err) => { + this.errorMsg(`Ready error: ${err}`); + }); + await this.writer.write(encMessage).catch((err) => { + this.errorMsg(`Chunk error: ${err}`); + }); + await this.writer.ready; + } + } + + async _readSerialLoop() { + if (!this.replSerialDevice) { + return; + } + + const messageEvent = new Event("message"); + const decoder = new TextDecoder(); + + if (this.replSerialDevice.readable) { + this.reader = this.replSerialDevice.readable.getReader(); + while (true) { + const {value, done} = await this.reader.read(); + if (value) { + messageEvent.data = decoder.decode(value); + this.replSerialDevice.dispatchEvent(messageEvent); + } + if (done) { + this.reader.releaseLock(); + await this.onReplDisconnected(); + break; + } + } + } + + this.logMsg("Read Loop Stopped. Closing Serial Port."); + } + + async getDeviceHostInfo() { + // For now return info from title + if (this.repl) { + return { + ip: this.repl.getIpAddress(), + version: this.repl.getVersion(), + }; + } + + return {}; + + // TODO: (Maybe) Retreive some device info via the REPL (mDNS Hostname and IP Address) + // import wifi + // import mdns + // wifi.radio.ipv4_address + // server = mdns.Server(wifi.radio) + // server.hostname + } + + // This is necessary because chips with native USB will have a CIRCUITPY drive, which blocks writing via REPL + hasNativeUsb() { + if (!this.chipFamily || ("esp32", "esp32c3").includes(this.chipFamily)) { + return false; + } + + // Since most new chips have it, we return true by default. + return true; + } + + sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + timeout(callback, ms) { + return Promise.race([callback(), this.sleep(ms).then(() => {throw Error("Timed Out");})]); + } +} + + +customElements.define('cp-install-button', CPInstallButton, {extends: "button"}); \ No newline at end of file diff --git a/dist/cpinstaller.min.js b/dist/cpinstaller.min.js new file mode 100644 index 0000000..39333d1 --- /dev/null +++ b/dist/cpinstaller.min.js @@ -0,0 +1,96 @@ +"use strict";import{html}from"https://unpkg.com/lit-html?module";import*as toml from"https://unpkg.com/iarna-toml-esm@3.0.5/toml-esm.mjs";import*as zip from"https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.6.65/+esm";import*as esptoolPackage from"https://unpkg.com/esp-web-flasher@5.1.2/dist/web/index.js?module";import{REPL}from"https://cdn.jsdelivr.net/gh/adafruit/circuitpython-repl-js@1.2.1/repl.js";import{InstallButton,ESP_ROM_BAUD}from"./base_installer.js";const PREFERRED_BAUDRATE=921600,COPY_CHUNK_SIZE=65536,DEFAULT_RELEASE_LATEST=!1,BOARD_DEFS="https://adafruit-circuit-python.s3.amazonaws.com/esp32_boards.json",CSS_DIALOG_CLASS="cp-installer-dialog",FAMILY_TO_CHIP_MAP={esp32s2:esptoolPackage.CHIP_FAMILY_ESP32S2,esp32s3:esptoolPackage.CHIP_FAMILY_ESP32S3,esp32c3:esptoolPackage.CHIP_FAMILY_ESP32C3,esp32:esptoolPackage.CHIP_FAMILY_ESP32},attrMap={bootloader:"bootloaderUrl",uf2file:"uf2FileUrl",binfile:"binFileUrl"};class CPInstallButton extends InstallButton{constructor(){super(),this.releaseVersion="[version]",this.boardName="ESP32-based device",this.boardId=null,this.bootloaderUrl=null,this.uf2FileUrl=null,this.binFileUrl=null,this.releaseVersion=0,this.chipFamily=null,this.dialogCssClass=CSS_DIALOG_CLASS,this.dialogs={...this.dialogs,...this.cpDialogs},this.bootDriveHandle=null,this.circuitpyDriveHandle=null,this._bootDriveName=null,this._serialPortName=null,this.replSerialDevice=null,this.repl=null,this.fileCache=[],this.reader=null,this.writer=null,this.tomlSettings=null,this.init()}static get observedAttributes(){return Object.keys(attrMap)}parseVersion(e){var t={},e=e.match(/(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)\.(\d+))?/);return e&&4<=e.length&&(t.major=e[1],t.minor=e[2],t.patch=e[3],e[4]&&e[5]?(t.suffix=e[4],t.suffixVersion=e[5]):(t.suffix="stable",t.suffixVersion=0)),t}sortReleases(e){const r=["major","minor","patch","suffix","suffixVersion"];return e.sort((e,t)=>{var i,s=this.parseVersion(e.version),a=this.parseVersion(t.version);for(i of r){if(s[i]a[i])return 1}return 0}),e}async connectedCallback(){this.boardId=this.getAttribute("boardid"),this.getAttribute("version")&&(this.releaseVersion=this.getAttribute("version"));var e=await(await fetch(BOARD_DEFS)).json();let t=null;if(Object.keys(e).includes(this.boardId)){e=e[this.boardId],e=(this.chipFamily=e.chipfamily,e.name&&(this.boardName=e.name),e.bootloader&&(this.bootloaderUrl=this.updateBinaryUrl(e.bootloader)),this.sortReleases(e.releases));if(this.releaseVersion)for(var i of e)if(i.version==this.releaseVersion){t=i;break}t||(t=DEFAULT_RELEASE_LATEST?e[e.length-1]:e[0],this.releaseVersion=t.version),t.uf2file&&(this.uf2FileUrl=this.updateBinaryUrl(t.uf2file)),t.binfile&&(this.binFileUrl=this.updateBinaryUrl(t.binfile))}this.getAttribute("chipfamily")&&(this.chipFamily=this.getAttribute("chipfamily")),this.getAttribute("boardname")&&(this.boardName=this.getAttribute("boardname")),this.menuTitle="CircuitPython Installer for "+this.boardName,super.connectedCallback()}attributeChangedCallback(e,t,i){this[attrMap[e]]=i?this.updateBinaryUrl(i):null}updateBinaryUrl(e){return e=e&&e.replace("https://downloads.circuitpython.org/","https://adafruit-circuit-python.s3.amazonaws.com/")}flows={uf2FullProgram:{label:"Full CircuitPython [version] Install",steps:[this.stepWelcome,this.stepSerialConnect,this.stepConfirm,this.stepEraseAll,this.stepBootloader,this.stepSelectBootDrive,this.stepCopyUf2,this.stepSelectCpyDrive,this.stepCredentials,this.stepSuccess],isEnabled:async()=>this.hasNativeUsb()&&!!this.bootloaderUrl&&!!this.uf2FileUrl},binFullProgram:{label:"Full CircuitPython [version] Install",steps:[this.stepWelcome,this.stepSerialConnect,this.stepConfirm,this.stepEraseAll,this.stepFlashBin,this.stepSetupRepl,this.stepCredentials,this.stepSuccess],isEnabled:async()=>!this.hasNativeUsb()&&!!this.binFileUrl},uf2Only:{label:"Upgrade/Install CircuitPython [version] UF2 Only",steps:[this.stepWelcome,this.stepSelectBootDrive,this.stepCopyUf2,this.stepSelectCpyDrive,this.stepCredentials,this.stepSuccess],isEnabled:async()=>this.hasNativeUsb()&&!!this.uf2FileUrl},binOnly:{label:"Upgrade CircuitPython [version] Bin Only",steps:[this.stepWelcome,this.stepSerialConnect,this.stepConfirm,this.stepEraseAll,this.stepFlashBin,this.stepSuccess],isEnabled:async()=>!!this.binFileUrl},bootloaderOnly:{label:"Install Bootloader Only",steps:[this.stepWelcome,this.stepSerialConnect,this.stepConfirm,this.stepEraseAll,this.stepBootloader,this.stepSuccess],isEnabled:async()=>this.hasNativeUsb()&&!!this.bootloaderUrl},credentialsOnlyRepl:{label:"Update WiFi credentials",steps:[this.stepWelcome,this.stepSetupRepl,this.stepCredentials,this.stepSuccess],isEnabled:async()=>!this.hasNativeUsb()},credentialsOnlyDrive:{label:"Update WiFi credentials",steps:[this.stepWelcome,this.stepSelectCpyDrive,this.stepCredentials,this.stepSuccess],isEnabled:async()=>this.hasNativeUsb()}};cpDialogs={welcome:{closeable:!0,template:e=>html` ++ Welcome to the CircuitPython Installer. This tool will install CircuitPython on your ${e.boardName}. +
++ This tool is new and experimental. If you experience any issues, feel free to check out + https://github.com/adafruit/circuitpython-org/issues + to see if somebody has already submitted the same issue you are experiencing. If not, feel free to open a new issue. If + you do see the same issue and are able to contribute additional information, that would be appreciated. +
++ If you are unable to use this tool, then the manual installation methods should still work. +
+ `},espSerialConnect:{closeable:!0,template:e=>html` ++ Make sure your board is plugged into this computer via a Serial connection using a USB Cable. +
++ + Click this button to open the Web Serial connection menu. +
+ +There may be many devices listed, such as your remembered Bluetooth peripherals, anything else plugged into USB, etc.
+ ++ If you aren't sure which to choose, look for words like "USB", "UART", "JTAG", and "Bridge Controller". There may be more than one right option depending on your system configuration. Experiment if needed. +
+ `,buttons:[this.previousButton,{label:"Next",onClick:this.nextStep,isEnabled:async()=>this.currentStepThis will overwrite everything on the ${e.boardName}.
+ `,buttons:[this.previousButton,{label:"Continue",onClick:this.nextStep}]},bootDriveSelect:{closeable:!0,template:e=>html` ++ Please select the ${e.drivename} Drive where the UF2 file will be copied. +
++ If you just installed the bootloader, you may need to reset your board. If you already had the bootloader installed, + you may need to double press the reset button. +
++ +
+ `,buttons:[]},circuitpyDriveSelect:{closeable:!0,template:e=>html` ++ Please select the CIRCUITPY Drive. If you don't see your CIRCUITPY drive, it may be disabled in boot.py or you may have renamed it at some point. +
++ +
+ `,buttons:[]},actionWaiting:{template:e=>html` +${e.action}...
+${e.action}...
+ + `,buttons:[]},cpSerial:{closeable:!0,template:e=>html` ++ The next step is to write your credentials to settings.toml. Make sure your board is running CircuitPython. If you just installed CircuitPython, you may to reset the board first. +
++ + Click this button to open the Web Serial connection menu. If it is already connected, pressing again will allow you to select a different port. +
+ +${e.serialPortInstructions}
+ `,buttons:[this.previousButton,{label:"Next",onClick:this.nextStep,isEnabled:async()=>this.currentStepSuccessfully Completed
+ ${e.ip?html`+ You can edit files by going to http://${e.ip}/code/. +
`:""} + `,buttons:[this.closeButton]},error:{closeable:!0,template:e=>html` +Installation Error: ${e.message}
+ `,buttons:[this.closeButton]}};async stepWelcome(){this.showDialog(this.dialogs.welcome,{boardName:this.boardName})}async stepSerialConnect(){this.showDialog(this.dialogs.espSerialConnect)}async stepConfirm(){this.showDialog(this.dialogs.confirm,{boardName:this.boardName})}async stepEraseAll(){this.showDialog(this.dialogs.actionWaiting,{action:"Erasing Flash"});try{await this.espStub.eraseFlash()}catch(e){this.errorMsg("Unable to finish erasing Flash memory. Please try again.")}await this.nextStep()}async stepFlashBin(){this.binFileUrl?(await this.downloadAndInstall(this.binFileUrl),await this.espHardReset(),await this.nextStep()):this.errorMsg("Missing bin file URL. Please make sure the installer button has this specified.")}async stepBootloader(){this.bootloaderUrl?(await this.downloadAndInstall(this.bootloaderUrl,"combined.bin",!0),await this.nextStep()):this.errorMsg("Missing bootloader file URL. Please make sure the installer button has this specified.")}async stepSelectBootDrive(){var e=await this.getBootDriveName();e&&this.logMsg("Waiting for user to select a bootloader volume named "+e),this.showDialog(this.dialogs.bootDriveSelect,{drivename:e||"Bootloader"})}async stepSelectCpyDrive(){this.logMsg("Waiting for user to select CIRCUITPY drive"),this.showDialog(this.dialogs.circuitpyDriveSelect)}async stepCopyUf2(){this.bootDriveHandle?(this.showDialog(this.dialogs.actionProgress,{action:"Copying "+this.uf2FileUrl}),await this.downloadAndCopy(this.uf2FileUrl),await this.nextStep()):this.errorMsg("No boot drive selected. stepSelectBootDrive should preceed this step.")}async stepSetupRepl(){var e=await this.getSerialPortName();let t=e?`There may be several devices listed, but look for one called something like ${e}.`:"There may be several devices listed. If you aren't sure which to choose, look for one that includes the name of your microcontroller.";this.showDialog(this.dialogs.cpSerial,{serialPortInstructions:t})}async stepCredentials(){this.tomlSettings=await this.getCurrentSettings(),console.log(this.tomlSettings);var e={wifi_ssid:this.getSetting("CIRCUITPY_WIFI_SSID"),wifi_password:this.getSetting("CIRCUITPY_WIFI_PASSWORD"),api_password:this.getSetting("CIRCUITPY_WEB_API_PASSWORD","passw0rd"),api_port:this.getSetting("CIRCUITPY_WEB_API_PORT",80)};this.hasNativeUsb(),this.showDialog(this.dialogs.credentials,e)}async stepSuccess(){let e={};this.repl&&(await this.repl.waitForPrompt(),this.currentFlow||this.currentFlow.steps.includes(this.stepCredentials))&&(e=await this.getDeviceHostInfo()),this.showDialog(this.dialogs.success,e)}async stepClose(){this.closeDialog()}async bootDriveSelectHandler(e){var t=await this.getBootDriveName();let i;try{i=await window.showDirectoryPicker({mode:"readwrite"})}catch(e){return}t&&t!=i.name?alert(`The selected drive named ${i.name} does not match the expected name of ${t}. Please select the correct drive.`):await this._verifyPermission(i)?(this.bootDriveHandle=i,await this.nextStep()):alert("Unable to write to the selected folder")}async circuitpyDriveSelectHandler(e){let t;try{t=await window.showDirectoryPicker({mode:"readwrite"})}catch(e){return}await this.getBootOut(t)?await this._verifyPermission(t)?(this.circuitpyDriveHandle=t,await this.nextStep()):alert("Unable to write to the selected folder"):alert("Expecting a folder with boot_out.txt. Please select the root folder of your CIRCUITPY drive.")}async espToolConnectHandler(e){await this.onReplDisconnected(e),await this.espDisconnect();let t;try{t=await this.espConnect({log:(...e)=>this.logMsg(...e),debug:()=>{},error:(...e)=>this.errorMsg(...e)})}catch(e){return void this.errorMsg("Unable to open Serial connection to board. Make sure the port is not already in use by another application or in another browser tab.")}try{this.updateEspConnected(this.connectionStates.CONNECTING),await t.initialize(),this.updateEspConnected(this.connectionStates.CONNECTED)}catch(e){return await t.disconnect(),this.updateEspConnected(this.connectionStates.DISCONNECTED),void this.errorMsg("Unable to connect to the board. Make sure it is in bootloader mode by holding the boot0 button when powering on and try again.")}try{this.logMsg("Connected to "+t.chipName),this.logMsg("MAC Address: "+this.formatMacAddr(t.macAddr())),FAMILY_TO_CHIP_MAP[this.chipFamily]==t.chipFamily?(this.logMsg("This chip checks out"),this.espStub=await t.runStub(),this.espStub.addEventListener("disconnect",()=>{this.updateEspConnected(this.connectionStates.DISCONNECTED),this.espStub=null}),await this.setBaudRateIfChipSupports(t.chipFamily,PREFERRED_BAUDRATE),await this.nextStep()):(this.errorMsg("Oops, this is the wrong firmware for your board."),await this.espDisconnect())}catch(e){await t.disconnect(),this.updateEspConnected(this.connectionStates.DISCONNECTED),this.errorMsg("Oops, we lost connection to your board before completing the install. Please check your USB connection and click Connect again. Refresh the browser if it becomes unresponsive.")}}async onSerialReceive(e){await this.repl.onSerialReceive(e)}async cpSerialConnectHandler(e){await this.espDisconnect(),await this.onReplDisconnected(e);try{this.replSerialDevice=await navigator.serial.requestPort()}catch(e){return}try{await this.replSerialDevice.open({baudRate:ESP_ROM_BAUD})}catch(e){console.error("Error. Unable to open Serial Port. Make sure it isn't already in use in another tab or application.")}await this.setupRepl(),this.nextStep()}async setupRepl(){this.replSerialDevice&&(this.repl=new REPL,this.repl.serialTransmit=this.serialTransmit.bind(this),this.replSerialDevice.addEventListener("message",this.onSerialReceive.bind(this)),this._readLoopPromise=this._readSerialLoop().catch(async function(e){await this.onReplDisconnected()}.bind(this)),this.replSerialDevice.writable)&&(this.writer=this.replSerialDevice.writable.getWriter(),await this.writer.ready)}async onReplDisconnected(e){if(this.reader){try{await this.reader.cancel()}catch(e){}this.reader=null}if(this.writer&&(await this.writer.releaseLock(),this.writer=null),this.replSerialDevice){try{await this.replSerialDevice.close()}catch(e){}this.replSerialDevice=null}}async getBootDriveName(){return this._bootDriveName||await this.extractBootloaderInfo(),this._bootDriveName}async getSerialPortName(){return this._serialPortName||await this.extractBootloaderInfo(),this._serialPortName}async _verifyPermission(e){var t={mode:"readwrite"};return"granted"===await e.queryPermission(t)||"granted"===await e.requestPermission(t)}async extractBootloaderInfo(){if(!this.bootloaderUrl)return!1;var[,e]=await this.downloadAndExtract(this.bootloaderUrl,"tinyuf2.bin"),e=await e.text();let t=e.match(/B\x00B\x00([A-Z0-9\x00]{11})FAT16/);t&&2<=t.length&&(this._bootDriveName=t[1].replace(/\0/g,"")),(t=e.match(/0123456789ABCDEF(.+)\x00UF2/))&&2<=t.length&&(this._serialPortName=t[1].replace(/\0/g," ")),this.removeCachedFile(this.bootloaderUrl.split("/").pop())}async getBootOut(e){return this.readFile("boot_out.txt",e)}async readFile(e,t=null){if(!(t=t||this.circuitpyDriveHandle))return console.warn("CIRCUITPY Drive not selected and no Directory Handle provided"),null;try{return await(await(await t.getFileHandle(e)).getFile()).text()}catch(e){return null}}async writeFile(e,t,i=null){if(!(i=i||this.circuitpyDriveHandle))return console.warn("CIRCUITPY Drive not selected and no Directory Handle provided"),null;i=await(await i.getFileHandle(e,{create:!0})).createWritable();await i.write(t),await i.close()}addCachedFile(e,t){this.fileCache.push({filename:e,blob:t})}getCachedFile(e){for(var t of this.fileCache)if(t.filename===e)return t.contents;return null}removeCachedFile(e){for(var t of this.fileCache)t.filename===e&&this.fileCache.splice(this.fileCache.indexOf(t),1)}async downloadFile(t,e){let i;try{i=await fetch(t)}catch(e){return this.errorMsg("Unable to download file: "+t),null}var s=i.body.getReader(),a=+i.headers.get("Content-Length");let r=0;for(var o=[];;){var{done:l,value:n}=await s.read();if(l)break;o.push(n),r+=n.length,e.value=Math.round(r/a*100),this.logMsg(`Received ${r} of `+a)}var h,c=new Uint8Array(r);let d=0;for(h of o)c.set(h,d),d+=h.length;return new Blob([c])}async downloadAndExtract(e,t=null,i=!1){let s=e.split("/").pop(),a=this.getCachedFile(s);var r;if(a||(this.showDialog(this.dialogs.actionProgress,{action:"Downloading "+s}),r=this.currentDialogElement.querySelector("#stepProgress"),a=await this.downloadFile(e,r),i&&this.addCachedFile(s,a)),s.endsWith(".zip")&&t){if(this.showDialog(this.dialogs.actionProgress,{action:"Extracting "+t}),[e,a]=await this.findAndExtractFromZip(a,t),!a)return void this.errorMsg(`Unable to find ${t} in `+s);s=e}return[s,a]}async downloadAndInstall(t,e=null,i=!1){var[t,e]=await this.downloadAndExtract(t,e,i);if(e){i=new Uint8Array(await e.arrayBuffer()).buffer;let s=0;this.showDialog(this.dialogs.actionProgress,{action:"Flashing "+t});const a=this.currentDialogElement.querySelector("#stepProgress");a.value=0;try{await this.espStub.flashData(i,(e,t)=>{var i=Math.round(e/t*100);i>s&&(a.value=i,this.logMsg(i+`% (${e}/${t})...`),s=i)},0,0)}catch(e){this.errorMsg(`Unable to flash file: ${t}. Error Message: `+e)}}}async downloadAndCopy(t,i=null){if(i=i||this.bootDriveHandle){var s,a=this.currentDialogElement.querySelector("#stepProgress"),[t,r]=(a.value=0,await this.downloadAndExtract(t)),o=await(await i.getFileHandle(t,{create:!0})).createWritable(),l=r.size;let e=0;for(;e