From 6efb9bb358d50fa56afd0252d4a2516f58fe799c Mon Sep 17 00:00:00 2001 From: Melissa LeBlanc-Williams Date: Fri, 10 Mar 2023 08:47:22 -0800 Subject: [PATCH] Adding initial files --- .gitignore | 120 +++++ LICENSE | 2 +- README.md | 10 +- base_installer.js | 465 +++++++++++++++++ boot.py | 3 + cpinstaller.js | 1177 ++++++++++++++++++++++++++++++++++++++++++ img/close_button.svg | 1 + 7 files changed, 1776 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 base_installer.js create mode 100644 boot.py create mode 100644 cpinstaller.js create mode 100644 img/close_button.svg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1140251 --- /dev/null +++ b/.gitignore @@ -0,0 +1,120 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +*.DS_Store diff --git a/LICENSE b/LICENSE index 6358d57..bb2aeae 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Melissa LeBlanc-Williams +Copyright (c) 2023 Melissa LeBlanc-Williams for Adafruit Industries Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b2cb211..fcc7524 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ # web-firmware-installer-js -Web Installation Tool for Firmware for Espressif ESP-based devices + +Web Installation Tool for Firmware for Espressif ESP-based devices. The base installer is intended +to be a general installer for different firmwares with specific application installers building +upon the base installer. + +## Specific Installers + +`cpinstaller.js` is the installation tool for CircuitPython + diff --git a/base_installer.js b/base_installer.js new file mode 100644 index 0000000..84a2f13 --- /dev/null +++ b/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: + + `, + buttons: [this.closeButton], + }, + menu: { + closeable: true, + template: (data) => html` +

${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`
  • No installable options available for this board.
  • `; + } + for (const [flowId, flow] of Object.entries(this.flows)) { + if (await flow.isEnabled()) { + yield templateFunc(flowId, flow); + } + } + } + + preloadDialogs() { + for (const [id, dialog] of Object.entries(this.dialogs)) { + if ('preload' in dialog && !dialog.preload) { + continue; + } + this.dialogElements[id] = this.getDialogElement(dialog); + } + } + + createIdFromLabel(text) { + return text.replace(/^[^a-z]+|[^\w:.-]+/gi, ""); + } + + createDialogElement(id, dialogData) { + // Check if an existing dialog with the same id exists and remove it if so + let existingDialog = this.querySelector(`#cp-installer-${id}`); + if (existingDialog) { + this.remove(existingDialog); + } + + // Create a dialog element + let dialogElement = document.createElement("dialog"); + dialogElement.id = id; + dialogElement.classList.add(this.dialogCssClass); + + // Add a close button + let closeButton = document.createElement("button"); + closeButton.href = "#"; + closeButton.classList.add("close-button"); + closeButton.addEventListener("click", (e) => { + e.preventDefault(); + dialogElement.close(); + }); + dialogElement.appendChild(closeButton); + + // Add a body element + let body = document.createElement("div"); + body.classList.add("dialog-body"); + dialogElement.appendChild(body); + + let buttons = this.defaultButtons; + if (dialogData && dialogData.buttons) { + buttons = dialogData.buttons; + } + + dialogElement.appendChild( + this.createNavigation(buttons) + ); + + // Return the dialog element + document.body.appendChild(dialogElement); + return dialogElement; + } + + createNavigation(buttonData) { + // Add buttons according to config data + const navigation = document.createElement("div"); + navigation.classList.add("dialog-navigation"); + + for (const button of buttonData) { + let buttonElement = document.createElement("button"); + buttonElement.innerText = button.label; + buttonElement.id = this.createIdFromLabel(button.label); + buttonElement.addEventListener("click", async (e) => { + e.preventDefault(); + await button.onClick.bind(this)(); + }); + buttonElement.addEventListener("update", async (e) => { + if ("onUpdate" in button) { + await button.onUpdate.bind(this)(e); + } + if ("isEnabled" in button) { + e.target.disabled = !(await button.isEnabled.bind(this)()); + } + }); + + navigation.appendChild(buttonElement); + } + + return navigation; + } + + getDialogElement(dialog, forceReload = false) { + function getKeyByValue(object, value) { + return Object.keys(object).find(key => object[key] === value); + } + + const dialogId = getKeyByValue(this.dialogs, dialog); + + if (dialogId) { + if (dialogId in this.dialogElements && !forceReload) { + return this.dialogElements[dialogId]; + } else { + return this.createDialogElement(dialogId, dialog); + } + } + return null; + } + + updateButtons() { + // Call each button's custom update event for the current dialog + if (this.currentDialogElement) { + const navButtons = this.currentDialogElement.querySelectorAll(".dialog-navigation button"); + for (const button of navButtons) { + button.dispatchEvent(new Event("update")); + } + } + } + + showDialog(dialog, templateData = {}) { + if (this.currentDialogElement) { + this.closeDialog(); + } + + this.currentDialogElement = this.getDialogElement(dialog); + if (!this.currentDialogElement) { + console.error(`Dialog not found`); + } + + if (this.currentDialogElement) { + const dialogBody = this.currentDialogElement.querySelector(".dialog-body"); + if ('template' in dialog) { + render(dialog.template(templateData), dialogBody); + } + + // Close button should probably hide during certain steps such as flashing and erasing + if ("closeable" in dialog && dialog.closeable) { + this.currentDialogElement.querySelector(".close-button").style.display = "block"; + } else { + this.currentDialogElement.querySelector(".close-button").style.display = "none"; + } + + let dialogButtons = this.defaultButtons; + if ('buttons' in dialog) { + dialogButtons = dialog.buttons; + } + + this.updateButtons(); + this.currentDialogElement.showModal(); + } + } + + closeDialog() { + this.currentDialogElement.close(); + this.currentDialogElement = null; + } + + errorMsg(text) { + text = this.stripHtml(text); + console.error(text); + this.showError(text); + } + + logMsg(text, showTrace = false) { + // TODO: Eventually add to an internal log that the user can bring up + console.info(this.stripHtml(text)); + if (showTrace) { + console.trace(); + } + } + + updateEspConnected(connected) { + if (Object.values(this.connectionStates).includes(connected)) { + this.connected = connected; + this.updateButtons(); + } + } + + stripHtml(html) { + let tmp = document.createElement("div"); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ""; + } + + formatMacAddr(macAddr) { + return macAddr.map((value) => value.toString(16).toUpperCase().padStart(2, "0")).join(":"); + } + + async disconnect() { + if (this.espStub) { + await espStub.disconnect(); + await espStub.port.close(); + this.updateUIConnected(this.connectionStates.DISCONNECTED); + this.espStub = null; + } + } + + async runFlow(flow) { + if (flow instanceof Event) { + flow.preventDefault(); + flow.stopImmediatePropagation(); + if (flow.target.id in this.flows) { + flow = this.flows[flow.target.id]; + } else { + return; + } + } + + this.currentFlow = flow; + this.currentStep = 0; + await this.currentFlow.steps[this.currentStep].bind(this)(); + } + + async nextStep() { + if (!this.currentFlow) { + return; + } + + if (this.currentStep < this.currentFlow.steps.length) { + this.currentStep++; + await this.currentFlow.steps[this.currentStep].bind(this)(); + } + } + + async prevStep() { + if (!this.currentFlow) { + return; + } + + if (this.currentStep > 0) { + this.currentStep--; + await this.currentFlow.steps[this.currentStep].bind(this)(); + } + } + + async showMenu() { + // Display Menu + this.showDialog(this.dialogs.menu); + } + + async showNotSupported() { + // Display Not Supported Message + this.showDialog(this.dialogs.notSupported); + } + + async showError(message) { + // Display Menu + this.showDialog(this.dialogs.error, {message: message}); + } + + async setBaudRateIfChipSupports(chipType, baud) { + if (baud == ESP_ROM_BAUD) { return } // already the default + + if (chipType == esptoolPackage.CHIP_FAMILY_ESP32) { // only supports the default + this.logMsg(`ESP32 Chip only works at 115200 instead of the preferred ${baud}. Staying at 115200...`); + return + } + + await this.changeBaudRate(baud); + } + + async changeBaudRate(baud) { + if (this.espStub && this.baudRates.includes(baud)) { + await this.espStub.setBaudrate(baud); + } + } + + async espHardReset(bootloader = false) { + if (this.espStub) { + await this.espStub.hardReset(bootloader); + } + } + + async espConnect(logger) { + // - Request a port and open a connection. + this.port = await navigator.serial.requestPort(); + + logger.log("Connecting..."); + await this.port.open({ baudRate: ESP_ROM_BAUD }); + + logger.log("Connected successfully."); + + return new esptoolPackage.ESPLoader(this.port, logger); + }; +} \ No newline at end of file diff --git a/boot.py b/boot.py new file mode 100644 index 0000000..ef37160 --- /dev/null +++ b/boot.py @@ -0,0 +1,3 @@ +import storage + +storage.disable_usb_drive() \ No newline at end of file diff --git a/cpinstaller.js b/cpinstaller.js new file mode 100644 index 0000000..dad21e6 --- /dev/null +++ b/cpinstaller.js @@ -0,0 +1,1177 @@ +// SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +'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 * as esptoolPackage from "https://adafruit.github.io/Adafruit_WebSerial_ESPTool/js/modules/esptool.js" +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"; + +// TODO: Combine multiple steps together. For now it was easier to make them separate, +// but for ease of configuration, it would be work better to combine them together. +// For instance stepSelectBootDrive and stepCopyUf2 should always be together and in +// that order, but due to having handlers in the first of those steps, it was easier to +// just call nextStep() from the handler. +// +// TODO: Hide the log and make it accessible via the menu (future feature, output to console for now) +// May need to deal with the fact that the ESPTool uses Web Serial and CircuitPython REPL uses Web Serial + +const PREFERRED_BAUDRATE = 921600; +const COPY_CHUNK_SIZE = 64 * 1024; // 64 KB Chunks + +const CSS_DIALOG_CLASS = "cp-installer-dialog"; +const FAMILY_TO_CHIP_MAP = { + 'esp32s2': esptoolPackage.CHIP_FAMILY_ESP32S2, + 'esp32s3': esptoolPackage.CHIP_FAMILY_ESP32S3, + 'esp32c3': esptoolPackage.CHIP_FAMILY_ESP32C3, + 'esp32': esptoolPackage.CHIP_FAMILY_ESP32 +} + +const attrMap = { + "bootloader": "bootloaderUrl", + "uf2file": "uf2FileUrl", + "binfile": "binFileUrl" +} + +export 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.bootloadId = 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); + } + + connectedCallback() { + this.boardName = this.getAttribute("boardname") || "ESP32-based device"; + this.menuTitle = `CircuitPython Installer for ${this.boardName}`; + + // If this is empty, it's a problem + this.boardId = this.getAttribute("boardid"); + this.releaseVersion = this.getAttribute("version"); + + // Nice to have for now + this.chipFamily = this.getAttribute("chipfamily"); + this.bootloaderId = this.getAttribute("bootloaderid"); // This could be used to check serial output from board matches the UF2 file + + super.connectedCallback(); + } + + attributeChangedCallback(attribute, previousValue, currentValue) { + const classVar = attrMap[attribute]; + this[classVar] = currentValue ? this.updateBinaryUrl(currentValue) : null; + } + + updateBinaryUrl(url) { + //if (location.hostname == "localhost") { + if (url) { + url = url.replace("https://downloads.circuitpython.org/", "https://adafruit-circuit-python.s3.amazonaws.com/"); + } + //} + + return url; + } + + // These are a series of the valid steps that should be part of a program flow + // Some steps currently need to be grouped together + flows = { + uf2FullProgram: { // Native USB Install + 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 () => { return this.hasNativeUsb() && !!this.bootloaderUrl && !!this.uf2FileUrl }, + }, + binFullProgram: { // Non-native USB Install (Once we have boot drive disable working, we can remove hasNativeUsb() check) + label: `Full CircuitPython [version] Install`, + steps: [this.stepWelcome, this.stepSerialConnect, this.stepConfirm, this.stepEraseAll, this.stepFlashBin, this.stepSetupRepl, this.stepCredentials, this.stepSuccess], + isEnabled: async () => { return !this.hasNativeUsb() && !!this.binFileUrl }, + }, + uf2Only: { // Upgrade when Bootloader is already installer + label: `Upgrade/Install CircuitPython [version] UF2 Only`, + steps: [this.stepWelcome, this.stepSelectBootDrive, this.stepCopyUf2, this.stepSelectCpyDrive, this.stepCredentials, this.stepSuccess], + isEnabled: async () => { return 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 () => { return !!this.binFileUrl }, + }, + bootloaderOnly: { // Used to allow UF2 Upgrade/Install + label: "Install Bootloader Only", + steps: [this.stepWelcome, this.stepSerialConnect, this.stepConfirm, this.stepEraseAll, this.stepBootloader, this.stepSuccess], + isEnabled: async () => { return this.hasNativeUsb() && !!this.bootloaderUrl }, + }, + credentialsOnlyRepl: { // Update via REPL + label: "Update WiFi credentials", + steps: [this.stepWelcome, this.stepSetupRepl, this.stepCredentials, this.stepSuccess], + isEnabled: async () => { return !this.hasNativeUsb() }, + }, + credentialsOnlyDrive: { // Update via CIRCUITPY Drive + label: "Update WiFi credentials", + steps: [this.stepWelcome, this.stepSelectCpyDrive, this.stepCredentials, this.stepSuccess], + isEnabled: async () => { return this.hasNativeUsb() }, + } + } + + // This is the data for the CircuitPython specific dialogs. Some are reused. + cpDialogs = { + welcome: { + closeable: true, + template: (data) => 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}...

    +
    + `, + buttons: [], + }, + actionProgress: { + template: (data) => html` +

    ${data.action}...

    + ${data.percentage}% + `, + 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` +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + ${data.mass_storage_disabled === true || data.mass_storage_disabled === false ? + 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/img/close_button.svg b/img/close_button.svg new file mode 100644 index 0000000..388edbf --- /dev/null +++ b/img/close_button.svg @@ -0,0 +1 @@ +close \ No newline at end of file