├── .prettierignore ├── .gitignore ├── src ├── version.ts ├── util │ ├── sleep.ts │ ├── timestamp-transformer.ts │ ├── reset.ts │ ├── file-download.ts │ ├── manifest.ts │ ├── fire-event.ts │ ├── line-break-transformer.ts │ ├── get-operating-system.ts │ └── console-color.ts ├── no-port-picked │ ├── index.ts │ └── no-port-picked-dialog.ts ├── components │ ├── ew-list.ts │ ├── ew-dialog.ts │ ├── ew-divider.ts │ ├── ew-checkbox.ts │ ├── ew-list-item.ts │ ├── ew-select-option.ts │ ├── ew-circular-progress.ts │ ├── ewt-dialog.ts │ ├── ew-icon-button.ts │ ├── ew-text-button.ts │ ├── ew-filled-select.ts │ ├── ew-filled-text-field.ts │ ├── svg.ts │ └── ewt-console.ts ├── pages │ ├── ewt-page-message.ts │ └── ewt-page-progress.ts ├── styles.ts ├── connect.ts ├── const.ts ├── install-button.ts ├── flash.ts └── install-dialog.ts ├── .npmignore ├── static ├── social.png ├── logos │ ├── 2smart.png │ ├── trmnl.png │ ├── wled.png │ ├── canairio.png │ ├── espeasy.png │ ├── clockwise.png │ ├── openspool.png │ ├── treadspan.png │ ├── luciferin_logo.png │ ├── openepaperlink.png │ ├── squeezelite-esp32.png │ ├── tasmota.svg │ ├── esphome.svg │ └── nspanelmanager.svg └── screenshots │ ├── logs.png │ └── dashboard.png ├── .github ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── release-drafter.yml │ ├── ci.yml │ └── npmpublish.yml ├── script ├── build ├── develop └── stubgen.py ├── tsconfig.json ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── package.json ├── rollup.config.mjs ├── README.md ├── LICENSE └── index.html /.prettierignore: -------------------------------------------------------------------------------- 1 | src/vendor 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export const version = "dev"; 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | index.html 2 | firmware_build 3 | demo 4 | -------------------------------------------------------------------------------- /static/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/social.png -------------------------------------------------------------------------------- /static/logos/2smart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/2smart.png -------------------------------------------------------------------------------- /static/logos/trmnl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/trmnl.png -------------------------------------------------------------------------------- /static/logos/wled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/wled.png -------------------------------------------------------------------------------- /static/logos/canairio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/canairio.png -------------------------------------------------------------------------------- /static/logos/espeasy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/espeasy.png -------------------------------------------------------------------------------- /static/logos/clockwise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/clockwise.png -------------------------------------------------------------------------------- /static/logos/openspool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/openspool.png -------------------------------------------------------------------------------- /static/logos/treadspan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/treadspan.png -------------------------------------------------------------------------------- /static/screenshots/logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/screenshots/logs.png -------------------------------------------------------------------------------- /src/util/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (time: number) => 2 | new Promise((resolve) => setTimeout(resolve, time)); 3 | -------------------------------------------------------------------------------- /static/logos/luciferin_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/luciferin_logo.png -------------------------------------------------------------------------------- /static/logos/openepaperlink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/openepaperlink.png -------------------------------------------------------------------------------- /static/screenshots/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/screenshots/dashboard.png -------------------------------------------------------------------------------- /static/logos/squeezelite-esp32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esphome/esp-web-tools/HEAD/static/logos/squeezelite-esp32.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: "npm" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | # Stop on errors 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | echo 'export const version =' `jq .version package.json`";" > src/version.ts 6 | 7 | rm -rf dist 8 | NODE_ENV=production npm exec -- tsc 9 | NODE_ENV=production npm exec -- rollup -c 10 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: "Breaking Changes" 3 | labels: 4 | - "breaking change" 5 | - title: "Dependencies" 6 | collapse-after: 1 7 | labels: 8 | - "dependencies" 9 | template: | 10 | ## What's Changed 11 | 12 | $CHANGES 13 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v6 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /src/no-port-picked/index.ts: -------------------------------------------------------------------------------- 1 | import "./no-port-picked-dialog"; 2 | 3 | export const openNoPortPickedDialog = async ( 4 | doTryAgain?: () => void, 5 | ): Promise => { 6 | const dialog = document.createElement("ewt-no-port-picked-dialog"); 7 | dialog.doTryAgain = doTryAgain; 8 | document.body.append(dialog); 9 | return true; 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/ew-list.ts: -------------------------------------------------------------------------------- 1 | import { List } from "@material/web/list/internal/list.js"; 2 | import { styles } from "@material/web/list/internal/list-styles.js"; 3 | 4 | declare global { 5 | interface HTMLElementTagNameMap { 6 | "ew-list": EwList; 7 | } 8 | } 9 | 10 | export class EwList extends List { 11 | static override styles = [styles]; 12 | } 13 | 14 | customElements.define("ew-list", EwList); 15 | -------------------------------------------------------------------------------- /script/develop: -------------------------------------------------------------------------------- 1 | # Stop on errors 2 | set -e 3 | 4 | if [ -z "$PORT" ]; then 5 | PORT=5001 6 | fi 7 | 8 | cd "$(dirname "$0")/.." 9 | 10 | rm -rf dist 11 | 12 | # Quit all background tasks when script exits 13 | trap "kill 0" EXIT 14 | 15 | # Run tsc once as rollup expects those files 16 | npm exec -- tsc || true 17 | 18 | npm exec -- serve -p "$PORT" & 19 | npm exec -- tsc --watch & 20 | npm exec -- rollup -c --watch 21 | -------------------------------------------------------------------------------- /src/components/ew-dialog.ts: -------------------------------------------------------------------------------- 1 | import { Dialog } from "@material/web/dialog/internal/dialog.js"; 2 | import { styles } from "@material/web/dialog/internal/dialog-styles.js"; 3 | 4 | declare global { 5 | interface HTMLElementTagNameMap { 6 | "ew-dialog": EwDialog; 7 | } 8 | } 9 | 10 | export class EwDialog extends Dialog { 11 | static override styles = [styles]; 12 | } 13 | 14 | customElements.define("ew-dialog", EwDialog); 15 | -------------------------------------------------------------------------------- /src/components/ew-divider.ts: -------------------------------------------------------------------------------- 1 | import { Divider } from "@material/web/divider/internal/divider.js"; 2 | import { styles } from "@material/web/divider/internal/divider-styles.js"; 3 | 4 | declare global { 5 | interface HTMLElementTagNameMap { 6 | "ew-divider": EwDivider; 7 | } 8 | } 9 | 10 | export class EwDivider extends Divider { 11 | static override styles = [styles]; 12 | } 13 | 14 | customElements.define("ew-divider", EwDivider); 15 | -------------------------------------------------------------------------------- /src/components/ew-checkbox.ts: -------------------------------------------------------------------------------- 1 | import { Checkbox } from "@material/web/checkbox/internal/checkbox.js"; 2 | import { styles } from "@material/web/checkbox/internal/checkbox-styles.js"; 3 | 4 | declare global { 5 | interface HTMLElementTagNameMap { 6 | "ew-checkbox": EwCheckbox; 7 | } 8 | } 9 | 10 | export class EwCheckbox extends Checkbox { 11 | static override styles = [styles]; 12 | } 13 | 14 | customElements.define("ew-checkbox", EwCheckbox); 15 | -------------------------------------------------------------------------------- /src/components/ew-list-item.ts: -------------------------------------------------------------------------------- 1 | import { ListItemEl as ListItem } from "@material/web/list/internal/listitem/list-item.js"; 2 | import { styles } from "@material/web/list/internal/listitem/list-item-styles.js"; 3 | 4 | declare global { 5 | interface HTMLElementTagNameMap { 6 | "ew-list-item": EwListItem; 7 | } 8 | } 9 | 10 | export class EwListItem extends ListItem { 11 | static override styles = [styles]; 12 | } 13 | 14 | customElements.define("ew-list-item", EwListItem); 15 | -------------------------------------------------------------------------------- /src/util/timestamp-transformer.ts: -------------------------------------------------------------------------------- 1 | export class TimestampTransformer implements Transformer { 2 | transform( 3 | chunk: string, 4 | controller: TransformStreamDefaultController, 5 | ) { 6 | const date = new Date(); 7 | const h = date.getHours().toString().padStart(2, "0"); 8 | const m = date.getMinutes().toString().padStart(2, "0"); 9 | const s = date.getSeconds().toString().padStart(2, "0"); 10 | controller.enqueue(`[${h}:${m}:${s}]${chunk}`); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/ew-select-option.ts: -------------------------------------------------------------------------------- 1 | import { styles } from "@material/web/menu/internal/menuitem/menu-item-styles.js"; 2 | import { SelectOptionEl } from "@material/web/select/internal/selectoption/select-option.js"; 3 | 4 | declare global { 5 | interface HTMLElementTagNameMap { 6 | "ew-select-option": EwSelectOption; 7 | } 8 | } 9 | 10 | export class EwSelectOption extends SelectOptionEl { 11 | static override styles = [styles]; 12 | } 13 | 14 | customElements.define("ew-select-option", EwSelectOption); 15 | -------------------------------------------------------------------------------- /static/logos/tasmota.svg: -------------------------------------------------------------------------------- 1 | Element 1 2 | -------------------------------------------------------------------------------- /src/components/ew-circular-progress.ts: -------------------------------------------------------------------------------- 1 | import { CircularProgress } from "@material/web/progress/internal/circular-progress.js"; 2 | import { styles } from "@material/web/progress/internal/circular-progress-styles.js"; 3 | 4 | declare global { 5 | interface HTMLElementTagNameMap { 6 | "ew-circular-progress": EwCircularProgress; 7 | } 8 | } 9 | 10 | export class EwCircularProgress extends CircularProgress { 11 | static override styles = [styles]; 12 | } 13 | 14 | customElements.define("ew-circular-progress", EwCircularProgress); 15 | -------------------------------------------------------------------------------- /src/util/reset.ts: -------------------------------------------------------------------------------- 1 | import { Transport } from "esptool-js"; 2 | import { sleep } from "./sleep"; 3 | 4 | export const hardReset = async (transport: Transport) => { 5 | console.log("Triggering reset"); 6 | await transport.device.setSignals({ 7 | dataTerminalReady: false, 8 | requestToSend: true, 9 | }); 10 | await sleep(250); 11 | await transport.device.setSignals({ 12 | dataTerminalReady: false, 13 | requestToSend: false, 14 | }); 15 | await sleep(250); 16 | await new Promise((resolve) => setTimeout(resolve, 1000)); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/ewt-dialog.ts: -------------------------------------------------------------------------------- 1 | import { DialogBase } from "@material/mwc-dialog/mwc-dialog-base"; 2 | import { styles } from "@material/mwc-dialog/mwc-dialog.css"; 3 | import { css } from "lit"; 4 | 5 | declare global { 6 | interface HTMLElementTagNameMap { 7 | "ewt-dialog": EwtDialog; 8 | } 9 | } 10 | 11 | export class EwtDialog extends DialogBase { 12 | static override styles = [ 13 | styles, 14 | css` 15 | .mdc-dialog__title { 16 | padding-right: 52px; 17 | } 18 | `, 19 | ]; 20 | } 21 | 22 | customElements.define("ewt-dialog", EwtDialog); 23 | -------------------------------------------------------------------------------- /src/components/ew-icon-button.ts: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@material/web/iconbutton/internal/icon-button.js"; 2 | import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles.js"; 3 | import { styles } from "@material/web/iconbutton/internal/standard-styles.js"; 4 | 5 | declare global { 6 | interface HTMLElementTagNameMap { 7 | "ew-icon-button": EwIconButton; 8 | } 9 | } 10 | 11 | export class EwIconButton extends IconButton { 12 | static override styles = [sharedStyles, styles]; 13 | } 14 | 15 | customElements.define("ew-icon-button", EwIconButton); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2019", "dom"], 4 | "target": "es2019", 5 | "module": "es2020", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "outDir": "dist", 9 | "declaration": true, 10 | "experimentalDecorators": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitReturns": true, 13 | "noUnusedLocals": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "importHelpers": true 18 | }, 19 | "include": ["src/*"] 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ew-text-button.ts: -------------------------------------------------------------------------------- 1 | import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.js"; 2 | import { TextButton } from "@material/web/button/internal/text-button.js"; 3 | import { styles as textStyles } from "@material/web/button/internal/text-styles.js"; 4 | 5 | declare global { 6 | interface HTMLElementTagNameMap { 7 | "ew-text-button": EwTextButton; 8 | } 9 | } 10 | 11 | export class EwTextButton extends TextButton { 12 | static override styles = [sharedStyles, textStyles]; 13 | } 14 | 15 | customElements.define("ew-text-button", EwTextButton); 16 | -------------------------------------------------------------------------------- /src/components/ew-filled-select.ts: -------------------------------------------------------------------------------- 1 | import { FilledSelect } from "@material/web/select/internal/filled-select.js"; 2 | import { styles } from "@material/web/select/internal/filled-select-styles.js"; 3 | import { styles as sharedStyles } from "@material/web/select/internal/shared-styles.js"; 4 | 5 | declare global { 6 | interface HTMLElementTagNameMap { 7 | "ew-filled-select": EwFilledSelect; 8 | } 9 | } 10 | 11 | export class EwFilledSelect extends FilledSelect { 12 | static override styles = [sharedStyles, styles]; 13 | } 14 | 15 | customElements.define("ew-filled-select", EwFilledSelect); 16 | -------------------------------------------------------------------------------- /src/util/file-download.ts: -------------------------------------------------------------------------------- 1 | export const fileDownload = (href: string, filename = ""): void => { 2 | const a = document.createElement("a"); 3 | a.target = "_blank"; 4 | a.href = href; 5 | a.download = filename; 6 | 7 | document.body.appendChild(a); 8 | a.dispatchEvent(new MouseEvent("click")); 9 | document.body.removeChild(a); 10 | }; 11 | 12 | export const textDownload = (text: string, filename = ""): void => { 13 | const blob = new Blob([text], { type: "text/plain" }); 14 | const url = URL.createObjectURL(blob); 15 | fileDownload(url, filename); 16 | setTimeout(() => URL.revokeObjectURL(url), 0); 17 | }; 18 | -------------------------------------------------------------------------------- /src/util/manifest.ts: -------------------------------------------------------------------------------- 1 | import { Manifest } from "../const"; 2 | 3 | export const downloadManifest = async (manifestPath: string) => { 4 | const manifestURL = new URL(manifestPath, location.toString()).toString(); 5 | const resp = await fetch(manifestURL); 6 | const manifest: Manifest = await resp.json(); 7 | 8 | if ("new_install_skip_erase" in manifest) { 9 | console.warn( 10 | 'Manifest option "new_install_skip_erase" is deprecated. Use "new_install_prompt_erase" instead.', 11 | ); 12 | if (manifest.new_install_skip_erase) { 13 | manifest.new_install_prompt_erase = true; 14 | } 15 | } 16 | 17 | return manifest; 18 | }; 19 | -------------------------------------------------------------------------------- /src/util/fire-event.ts: -------------------------------------------------------------------------------- 1 | export const fireEvent = ( 2 | eventTarget: EventTarget, 3 | type: Event, 4 | // @ts-ignore 5 | detail?: HTMLElementEventMap[Event]["detail"], 6 | options?: { 7 | bubbles?: boolean; 8 | cancelable?: boolean; 9 | composed?: boolean; 10 | }, 11 | ): void => { 12 | options = options || {}; 13 | const event = new CustomEvent(type, { 14 | bubbles: options.bubbles === undefined ? true : options.bubbles, 15 | cancelable: Boolean(options.cancelable), 16 | composed: options.composed === undefined ? true : options.composed, 17 | detail, 18 | }); 19 | eventTarget.dispatchEvent(event); 20 | }; 21 | -------------------------------------------------------------------------------- /src/util/line-break-transformer.ts: -------------------------------------------------------------------------------- 1 | export class LineBreakTransformer implements Transformer { 2 | private chunks = ""; 3 | 4 | transform( 5 | chunk: string, 6 | controller: TransformStreamDefaultController, 7 | ) { 8 | // Append new chunks to existing chunks. 9 | this.chunks += chunk; 10 | // For each line breaks in chunks, send the parsed lines out. 11 | const lines = this.chunks.split(/\r?\n/); 12 | this.chunks = lines.pop()!; 13 | lines.forEach((line) => controller.enqueue(line + "\r\n")); 14 | } 15 | 16 | flush(controller: TransformStreamDefaultController) { 17 | // When the stream is closed, flush any remaining chunks out. 18 | controller.enqueue(this.chunks); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ew-filled-text-field.ts: -------------------------------------------------------------------------------- 1 | import { styles as filledStyles } from "@material/web/textfield/internal/filled-styles.js"; 2 | import { FilledTextField } from "@material/web/textfield/internal/filled-text-field.js"; 3 | import { styles as sharedStyles } from "@material/web/textfield/internal/shared-styles.js"; 4 | import { literal } from "lit/static-html.js"; 5 | 6 | declare global { 7 | interface HTMLElementTagNameMap { 8 | "ew-filled-text-field": EwFilledTextField; 9 | } 10 | } 11 | 12 | export class EwFilledTextField extends FilledTextField { 13 | static override styles = [sharedStyles, filledStyles]; 14 | protected override readonly fieldTag = literal`md-filled-field`; 15 | } 16 | 17 | customElements.define("ew-filled-text-field", EwFilledTextField); 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v6 18 | - name: Install jq tool 19 | run: | 20 | sudo apt-get update 21 | sudo apt-get install jq 22 | - name: Use Node.js 23 | uses: actions/setup-node@v6 24 | with: 25 | node-version: 16 26 | - run: npm ci 27 | - run: script/build 28 | - run: npm exec -- prettier --check src 29 | -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v6 15 | - name: Install jq tool 16 | run: | 17 | sudo apt-get update 18 | sudo apt-get install jq 19 | - uses: actions/setup-node@v6 20 | with: 21 | node-version: 16 22 | registry-url: https://registry.npmjs.org/ 23 | - run: npm ci 24 | - run: npm publish 25 | env: 26 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 27 | -------------------------------------------------------------------------------- /src/pages/ewt-page-message.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, TemplateResult } from "lit"; 2 | import { property } from "lit/decorators.js"; 3 | 4 | class EwtPageMessage extends LitElement { 5 | @property() icon!: string; 6 | 7 | @property() label!: string | TemplateResult; 8 | 9 | render() { 10 | return html` 11 |
${this.icon}
12 | ${this.label} 13 | `; 14 | } 15 | 16 | static styles = css` 17 | :host { 18 | display: flex; 19 | flex-direction: column; 20 | text-align: center; 21 | } 22 | .icon { 23 | font-size: 50px; 24 | line-height: 80px; 25 | color: black; 26 | } 27 | `; 28 | } 29 | customElements.define("ewt-page-message", EwtPageMessage); 30 | 31 | declare global { 32 | interface HTMLElementTagNameMap { 33 | "ewt-page-message": EwtPageMessage; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/typescript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version: 16, 14, 12 4 | ARG VARIANT="16-buster" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node packages 16 | # RUN su node -c "npm install -g " 17 | -------------------------------------------------------------------------------- /src/util/get-operating-system.ts: -------------------------------------------------------------------------------- 1 | // From https://stackoverflow.com/a/38241481 2 | export const getOperatingSystem = () => { 3 | const userAgent = window.navigator.userAgent; 4 | const platform = 5 | // @ts-expect-error 6 | window.navigator?.userAgentData?.platform || window.navigator.platform; 7 | const macosPlatforms = ["macOS", "Macintosh", "MacIntel", "MacPPC", "Mac68K"]; 8 | const windowsPlatforms = ["Win32", "Win64", "Windows", "WinCE"]; 9 | const iosPlatforms = ["iPhone", "iPad", "iPod"]; 10 | 11 | if (macosPlatforms.indexOf(platform) !== -1) { 12 | return "Mac OS"; 13 | } else if (iosPlatforms.indexOf(platform) !== -1) { 14 | return "iOS"; 15 | } else if (windowsPlatforms.indexOf(platform) !== -1) { 16 | return "Windows"; 17 | } else if (/Android/.test(userAgent)) { 18 | return "Android"; 19 | } else if (/Linux/.test(platform)) { 20 | return "Linux"; 21 | } 22 | 23 | return null; 24 | }; 25 | -------------------------------------------------------------------------------- /src/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "lit"; 2 | 3 | // We set font-size to 16px and all the mdc typography styles 4 | // because it defaults to rem, which means that the font-size 5 | // of the host website would influence the ESP Web Tools dialog. 6 | 7 | export const dialogStyles = css` 8 | :host { 9 | --roboto-font: Roboto, system-ui; 10 | --text-color: rgba(0, 0, 0, 0.6); 11 | --danger-color: #db4437; 12 | 13 | --md-sys-color-primary: #03a9f4; 14 | --md-sys-color-on-primary: #fff; 15 | --md-ref-typeface-brand: var(--roboto-font); 16 | --md-ref-typeface-plain: var(--roboto-font); 17 | 18 | --md-sys-color-surface: #fff; 19 | --md-sys-color-surface-container: #fff; 20 | --md-sys-color-surface-container-high: #fff; 21 | --md-sys-color-surface-container-highest: #f5f5f5; 22 | --md-sys-color-secondary-container: #e0e0e0; 23 | 24 | --md-sys-typescale-headline-font: var(--roboto-font); 25 | --md-sys-typescale-title-font: var(--roboto-font); 26 | } 27 | 28 | a { 29 | color: var(--md-sys-color-primary); 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esp-web-tools", 3 | "version": "10.1.1", 4 | "description": "Web tools for ESP devices", 5 | "main": "dist/install-button.js", 6 | "repository": "https://github.com/esphome/esp-web-tools", 7 | "author": "ESPHome maintainers", 8 | "license": "Apache-2.0", 9 | "scripts": { 10 | "prepublishOnly": "script/build" 11 | }, 12 | "devDependencies": { 13 | "@babel/preset-env": "^7.26.0", 14 | "@rollup/plugin-babel": "^6.0.4", 15 | "@rollup/plugin-commonjs": "^29.0.0", 16 | "@rollup/plugin-json": "^6.1.0", 17 | "@rollup/plugin-node-resolve": "^16.0.0", 18 | "@rollup/plugin-terser": "^0.4.4", 19 | "@rollup/plugin-typescript": "^12.1.2", 20 | "@types/w3c-web-serial": "^1.0.7", 21 | "prettier": "^3.4.2", 22 | "rollup": "^4.29.1", 23 | "serve": "^14.2.4", 24 | "typescript": "^5.7.2" 25 | }, 26 | "dependencies": { 27 | "@material/web": "^2.2.0", 28 | "esptool-js": "^0.5.3", 29 | "improv-wifi-serial-sdk": "^2.5.0", 30 | "lit": "^3.2.1", 31 | "pako": "^2.1.0", 32 | "tslib": "^2.8.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/connect.ts: -------------------------------------------------------------------------------- 1 | import type { InstallButton } from "./install-button.js"; 2 | 3 | export const connect = async (button: InstallButton) => { 4 | import("./install-dialog.js"); 5 | let port: SerialPort | undefined; 6 | try { 7 | port = await navigator.serial.requestPort(); 8 | } catch (err: any) { 9 | if ((err as DOMException).name === "NotFoundError") { 10 | import("./no-port-picked/index").then((mod) => 11 | mod.openNoPortPickedDialog(() => connect(button)), 12 | ); 13 | return; 14 | } 15 | alert(`Error: ${err.message}`); 16 | return; 17 | } 18 | 19 | if (!port) { 20 | return; 21 | } 22 | 23 | try { 24 | await port.open({ baudRate: 115200, bufferSize: 8192 }); 25 | } catch (err: any) { 26 | alert(err.message); 27 | return; 28 | } 29 | 30 | const el = document.createElement("ewt-install-dialog"); 31 | el.port = port; 32 | el.manifestPath = button.manifest || button.getAttribute("manifest")!; 33 | el.overrides = button.overrides; 34 | el.addEventListener( 35 | "closed", 36 | () => { 37 | port!.close(); 38 | }, 39 | { once: true }, 40 | ); 41 | document.body.appendChild(el); 42 | }; 43 | -------------------------------------------------------------------------------- /src/pages/ewt-page-progress.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, TemplateResult } from "lit"; 2 | import { property } from "lit/decorators.js"; 3 | import "../components/ew-circular-progress"; 4 | 5 | class EwtPageProgress extends LitElement { 6 | @property() label!: string | TemplateResult; 7 | 8 | @property() progress: number | undefined; 9 | 10 | render() { 11 | return html` 12 |
13 | 20 | ${this.progress !== undefined ? html`
${this.progress}%
` : ""} 21 |
22 | ${this.label} 23 | `; 24 | } 25 | 26 | static styles = css` 27 | :host { 28 | display: flex; 29 | flex-direction: column; 30 | text-align: center; 31 | } 32 | ew-circular-progress { 33 | margin-bottom: 16px; 34 | } 35 | `; 36 | } 37 | customElements.define("ewt-page-progress", EwtPageProgress); 38 | 39 | declare global { 40 | interface HTMLElementTagNameMap { 41 | "ewt-page-progress": EwtPageProgress; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import nodeResolve from "@rollup/plugin-node-resolve"; 2 | import json from "@rollup/plugin-json"; 3 | import terser from "@rollup/plugin-terser"; 4 | import babel from "@rollup/plugin-babel"; 5 | import commonjs from "@rollup/plugin-commonjs"; 6 | 7 | const config = { 8 | input: "dist/install-button.js", 9 | output: { 10 | dir: "dist/web", 11 | format: "module", 12 | }, 13 | external: ["https://www.improv-wifi.com/sdk-js/launch-button.js"], 14 | preserveEntrySignatures: false, 15 | plugins: [ 16 | commonjs(), 17 | nodeResolve({ 18 | browser: true, 19 | preferBuiltins: false, 20 | }), 21 | babel({ 22 | babelHelpers: "bundled", 23 | presets: [ 24 | [ 25 | "@babel/preset-env", 26 | { 27 | targets: { 28 | // We use unpkg as CDN and it doesn't bundle modern syntax 29 | chrome: "84", 30 | }, 31 | }, 32 | ], 33 | ], 34 | }), 35 | json(), 36 | ], 37 | }; 38 | 39 | if (process.env.NODE_ENV === "production") { 40 | config.plugins.push( 41 | terser({ 42 | ecma: 2019, 43 | toplevel: true, 44 | format: { 45 | comments: false, 46 | }, 47 | }) 48 | ); 49 | } 50 | 51 | export default config; 52 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 12, 14, 16, 18, 20 8 | "args": { 9 | "VARIANT": "20" 10 | } 11 | }, 12 | 13 | // Add the IDs of extensions you want installed when the container is created. 14 | "extensions": [ 15 | "dbaeumer.vscode-eslint", 16 | "esbenp.prettier-vscode", 17 | "bierner.lit-html", 18 | "runem.lit-plugin" 19 | ], 20 | 21 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 22 | "forwardPorts": [5000], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | "postCreateCommand": "npm install", 26 | 27 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 28 | "remoteUser": "node", 29 | 30 | "settings": { 31 | "files.eol": "\n", 32 | "editor.tabSize": 2, 33 | "editor.formatOnPaste": false, 34 | "editor.formatOnSave": true, 35 | "editor.formatOnType": true, 36 | "[typescript]": { 37 | "editor.defaultFormatter": "esbenp.prettier-vscode" 38 | }, 39 | "[javascript]": { 40 | "editor.defaultFormatter": "esbenp.prettier-vscode" 41 | }, 42 | "files.trimTrailingWhitespace": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /static/logos/esphome.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ESP Web Tools 2 | 3 | Allow flashing ESPHome or other ESP-based firmwares via the browser. Will automatically detect the board type and select a supported firmware. [See website for full documentation.](https://esphome.github.io/esp-web-tools/) 4 | 5 | ```html 6 | 9 | ``` 10 | 11 | Example manifest: 12 | 13 | ```json 14 | { 15 | "name": "ESPHome", 16 | "version": "2021.10.3", 17 | "home_assistant_domain": "esphome", 18 | "funding_url": "https://esphome.io/guides/supporters.html", 19 | "builds": [ 20 | { 21 | "chipFamily": "ESP32", 22 | "parts": [ 23 | { "path": "bootloader_dout_40m.bin", "offset": 4096 }, 24 | { "path": "partitions.bin", "offset": 32768 }, 25 | { "path": "boot_app0.bin", "offset": 57344 }, 26 | { "path": "esp32.bin", "offset": 65536 } 27 | ] 28 | }, 29 | { 30 | "chipFamily": "ESP32-C3", 31 | "parts": [ 32 | { "path": "bootloader_dout_40m.bin", "offset": 0 }, 33 | { "path": "partitions.bin", "offset": 32768 }, 34 | { "path": "boot_app0.bin", "offset": 57344 }, 35 | { "path": "esp32-c3.bin", "offset": 65536 } 36 | ] 37 | }, 38 | { 39 | "chipFamily": "ESP32-S2", 40 | "parts": [ 41 | { "path": "bootloader_dout_40m.bin", "offset": 4096 }, 42 | { "path": "partitions.bin", "offset": 32768 }, 43 | { "path": "boot_app0.bin", "offset": 57344 }, 44 | { "path": "esp32-s2.bin", "offset": 65536 } 45 | ] 46 | }, 47 | { 48 | "chipFamily": "ESP32-S3", 49 | "parts": [ 50 | { "path": "bootloader_dout_40m.bin", "offset": 4096 }, 51 | { "path": "partitions.bin", "offset": 32768 }, 52 | { "path": "boot_app0.bin", "offset": 57344 }, 53 | { "path": "esp32-s3.bin", "offset": 65536 } 54 | ] 55 | }, 56 | { 57 | "chipFamily": "ESP8266", 58 | "parts": [ 59 | { "path": "esp8266.bin", "offset": 0 } 60 | ] 61 | } 62 | ] 63 | } 64 | ``` 65 | 66 | ## Development 67 | 68 | Run `script/develop`. This starts a server. Open it on http://localhost:5001. 69 | 70 | [![ESPHome - A project from the Open Home Foundation](https://www.openhomefoundation.org/badges/esphome.png)](https://www.openhomefoundation.org/) 71 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | log(msg: string, ...args: any[]): void; 3 | error(msg: string, ...args: any[]): void; 4 | debug(msg: string, ...args: any[]): void; 5 | } 6 | 7 | export interface Build { 8 | chipFamily: 9 | | "ESP32" 10 | | "ESP32-C2" 11 | | "ESP32-C3" 12 | | "ESP32-C6" 13 | | "ESP32-H2" 14 | | "ESP32-S2" 15 | | "ESP32-S3" 16 | | "ESP8266"; 17 | parts: { 18 | path: string; 19 | offset: number; 20 | }[]; 21 | } 22 | 23 | export interface Manifest { 24 | name: string; 25 | version: string; 26 | home_assistant_domain?: string; 27 | funding_url?: string; 28 | /** @deprecated use `new_install_prompt_erase` instead */ 29 | new_install_skip_erase?: boolean; 30 | new_install_prompt_erase?: boolean; 31 | /* Time to wait to detect Improv Wi-Fi. Set to 0 to disable. */ 32 | new_install_improv_wait_time?: number; 33 | builds: Build[]; 34 | } 35 | 36 | export interface BaseFlashState { 37 | state: FlashStateType; 38 | message: string; 39 | manifest?: Manifest; 40 | build?: Build; 41 | chipFamily?: Build["chipFamily"] | "Unknown Chip"; 42 | } 43 | 44 | export interface InitializingState extends BaseFlashState { 45 | state: FlashStateType.INITIALIZING; 46 | details: { done: boolean }; 47 | } 48 | 49 | export interface PreparingState extends BaseFlashState { 50 | state: FlashStateType.PREPARING; 51 | details: { done: boolean }; 52 | } 53 | 54 | export interface ErasingState extends BaseFlashState { 55 | state: FlashStateType.ERASING; 56 | details: { done: boolean }; 57 | } 58 | 59 | export interface WritingState extends BaseFlashState { 60 | state: FlashStateType.WRITING; 61 | details: { bytesTotal: number; bytesWritten: number; percentage: number }; 62 | } 63 | 64 | export interface FinishedState extends BaseFlashState { 65 | state: FlashStateType.FINISHED; 66 | } 67 | 68 | export interface ErrorState extends BaseFlashState { 69 | state: FlashStateType.ERROR; 70 | details: { error: FlashError; details: string | Error }; 71 | } 72 | 73 | export type FlashState = 74 | | InitializingState 75 | | PreparingState 76 | | ErasingState 77 | | WritingState 78 | | FinishedState 79 | | ErrorState; 80 | 81 | export const enum FlashStateType { 82 | INITIALIZING = "initializing", 83 | PREPARING = "preparing", 84 | ERASING = "erasing", 85 | WRITING = "writing", 86 | FINISHED = "finished", 87 | ERROR = "error", 88 | } 89 | 90 | export const enum FlashError { 91 | FAILED_INITIALIZING = "failed_initialize", 92 | FAILED_MANIFEST_FETCH = "fetch_manifest_failed", 93 | NOT_SUPPORTED = "not_supported", 94 | FAILED_FIRMWARE_DOWNLOAD = "failed_firmware_download", 95 | WRITE_FAILED = "write_failed", 96 | } 97 | 98 | declare global { 99 | interface HTMLElementEventMap { 100 | "state-changed": CustomEvent; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/install-button.ts: -------------------------------------------------------------------------------- 1 | import type { FlashState } from "./const"; 2 | import type { EwtInstallDialog } from "./install-dialog"; 3 | import { connect } from "./connect"; 4 | 5 | export class InstallButton extends HTMLElement { 6 | public static isSupported = "serial" in navigator; 7 | 8 | public static isAllowed = window.isSecureContext; 9 | 10 | private static style = ` 11 | button { 12 | position: relative; 13 | cursor: pointer; 14 | font-size: 14px; 15 | font-weight: 500; 16 | padding: 10px 24px; 17 | color: var(--esp-tools-button-text-color, #fff); 18 | background-color: var(--esp-tools-button-color, #03a9f4); 19 | border: none; 20 | border-radius: var(--esp-tools-button-border-radius, 9999px); 21 | } 22 | button::before { 23 | content: " "; 24 | position: absolute; 25 | top: 0; 26 | bottom: 0; 27 | left: 0; 28 | right: 0; 29 | opacity: 0.2; 30 | border-radius: var(--esp-tools-button-border-radius, 9999px); 31 | } 32 | button:hover::before { 33 | background-color: rgba(255,255,255,.8); 34 | } 35 | button:focus { 36 | outline: none; 37 | } 38 | button:focus::before { 39 | background-color: white; 40 | } 41 | button:active::before { 42 | background-color: grey; 43 | } 44 | :host([active]) button { 45 | color: rgba(0, 0, 0, 0.38); 46 | background-color: rgba(0, 0, 0, 0.12); 47 | box-shadow: none; 48 | cursor: unset; 49 | pointer-events: none; 50 | } 51 | .hidden { 52 | display: none; 53 | }`; 54 | 55 | public manifest?: string; 56 | 57 | public eraseFirst?: boolean; 58 | 59 | public hideProgress?: boolean; 60 | 61 | public showLog?: boolean; 62 | 63 | public logConsole?: boolean; 64 | 65 | public state?: FlashState; 66 | 67 | public renderRoot?: ShadowRoot; 68 | 69 | public overrides: EwtInstallDialog["overrides"]; 70 | 71 | public connectedCallback() { 72 | if (this.renderRoot) { 73 | return; 74 | } 75 | 76 | this.renderRoot = this.attachShadow({ mode: "open" }); 77 | 78 | if (!InstallButton.isSupported || !InstallButton.isAllowed) { 79 | this.toggleAttribute("install-unsupported", true); 80 | this.renderRoot.innerHTML = !InstallButton.isAllowed 81 | ? "You can only install ESP devices on HTTPS websites or on the localhost." 82 | : "Your browser does not support installing things on ESP devices. Use Google Chrome or Microsoft Edge."; 83 | return; 84 | } 85 | 86 | this.toggleAttribute("install-supported", true); 87 | 88 | const slot = document.createElement("slot"); 89 | 90 | slot.addEventListener("click", async (ev) => { 91 | ev.preventDefault(); 92 | connect(this); 93 | }); 94 | 95 | slot.name = "activate"; 96 | const button = document.createElement("button"); 97 | button.innerText = "Connect"; 98 | slot.append(button); 99 | if ( 100 | "adoptedStyleSheets" in Document.prototype && 101 | "replaceSync" in CSSStyleSheet.prototype 102 | ) { 103 | const sheet = new CSSStyleSheet(); 104 | sheet.replaceSync(InstallButton.style); 105 | this.renderRoot.adoptedStyleSheets = [sheet]; 106 | } else { 107 | const styleSheet = document.createElement("style"); 108 | styleSheet.innerText = InstallButton.style; 109 | this.renderRoot.append(styleSheet); 110 | } 111 | this.renderRoot.append(slot); 112 | } 113 | } 114 | 115 | customElements.define("esp-web-install-button", InstallButton); 116 | -------------------------------------------------------------------------------- /src/components/svg.ts: -------------------------------------------------------------------------------- 1 | import { svg } from "lit"; 2 | 3 | export const closeIcon = svg` 4 | 5 | 9 | 10 | `; 11 | 12 | export const refreshIcon = svg` 13 | 14 | 18 | 19 | `; 20 | 21 | export const listItemInstallIcon = svg` 22 | 23 | 24 | 25 | `; 26 | 27 | export const listItemWifi = svg` 28 | 29 | 30 | 31 | `; 32 | 33 | export const listItemConsole = svg` 34 | 35 | 36 | 37 | `; 38 | 39 | export const listItemVisitDevice = svg` 40 | 41 | 42 | 43 | `; 44 | 45 | export const listItemHomeAssistant = svg` 46 | 47 | 48 | 49 | `; 50 | 51 | export const listItemEraseUserData = svg` 52 | 53 | 54 | 55 | `; 56 | 57 | export const listItemFundDevelopment = svg` 58 | 59 | 60 | 61 | `; 62 | -------------------------------------------------------------------------------- /src/components/ewt-console.ts: -------------------------------------------------------------------------------- 1 | import { ColoredConsole, coloredConsoleStyles } from "../util/console-color"; 2 | import { sleep } from "../util/sleep"; 3 | import { LineBreakTransformer } from "../util/line-break-transformer"; 4 | import { TimestampTransformer } from "../util/timestamp-transformer"; 5 | import { Logger } from "../const"; 6 | 7 | export class EwtConsole extends HTMLElement { 8 | public port!: SerialPort; 9 | public logger!: Logger; 10 | public allowInput = true; 11 | 12 | private _console?: ColoredConsole; 13 | private _cancelConnection?: () => Promise; 14 | 15 | public logs(): string { 16 | return this._console?.logs() || ""; 17 | } 18 | 19 | public connectedCallback() { 20 | if (this._console) { 21 | return; 22 | } 23 | const shadowRoot = this.attachShadow({ mode: "open" }); 24 | 25 | shadowRoot.innerHTML = ` 26 | 50 |
51 | ${ 52 | this.allowInput 53 | ? `
54 | > 55 | 56 |
57 | ` 58 | : "" 59 | } 60 | `; 61 | 62 | this._console = new ColoredConsole(this.shadowRoot!.querySelector("div")!); 63 | 64 | if (this.allowInput) { 65 | const input = this.shadowRoot!.querySelector("input")!; 66 | 67 | this.addEventListener("click", () => { 68 | // Only focus input if user didn't select some text 69 | if (getSelection()?.toString() === "") { 70 | input.focus(); 71 | } 72 | }); 73 | 74 | input.addEventListener("keydown", (ev) => { 75 | if (ev.key === "Enter") { 76 | ev.preventDefault(); 77 | ev.stopPropagation(); 78 | this._sendCommand(); 79 | } 80 | }); 81 | } 82 | 83 | const abortController = new AbortController(); 84 | const connection = this._connect(abortController.signal); 85 | this._cancelConnection = () => { 86 | abortController.abort(); 87 | return connection; 88 | }; 89 | } 90 | 91 | private async _connect(abortSignal: AbortSignal) { 92 | this.logger.debug("Starting console read loop"); 93 | try { 94 | await this.port 95 | .readable!.pipeThrough(new TextDecoderStream(), { 96 | signal: abortSignal, 97 | }) 98 | .pipeThrough(new TransformStream(new LineBreakTransformer())) 99 | .pipeThrough(new TransformStream(new TimestampTransformer())) 100 | .pipeTo( 101 | new WritableStream({ 102 | write: (chunk) => { 103 | this._console!.addLine(chunk.replace("\r", "")); 104 | }, 105 | }), 106 | ); 107 | if (!abortSignal.aborted) { 108 | this._console!.addLine(""); 109 | this._console!.addLine(""); 110 | this._console!.addLine("Terminal disconnected"); 111 | } 112 | } catch (e) { 113 | this._console!.addLine(""); 114 | this._console!.addLine(""); 115 | this._console!.addLine(`Terminal disconnected: ${e}`); 116 | } finally { 117 | await sleep(100); 118 | this.logger.debug("Finished console read loop"); 119 | } 120 | } 121 | 122 | private async _sendCommand() { 123 | const input = this.shadowRoot!.querySelector("input")!; 124 | const command = input.value; 125 | const encoder = new TextEncoder(); 126 | const writer = this.port.writable!.getWriter(); 127 | await writer.write(encoder.encode(command + "\r\n")); 128 | this._console!.addLine(`> ${command}\r\n`); 129 | input.value = ""; 130 | input.focus(); 131 | try { 132 | writer.releaseLock(); 133 | } catch (err) { 134 | console.error("Ignoring release lock error", err); 135 | } 136 | } 137 | 138 | public async disconnect() { 139 | if (this._cancelConnection) { 140 | await this._cancelConnection(); 141 | this._cancelConnection = undefined; 142 | } 143 | } 144 | 145 | public async reset() { 146 | this.logger.debug("Triggering reset"); 147 | await this.port.setSignals({ 148 | dataTerminalReady: false, 149 | requestToSend: true, 150 | }); 151 | await sleep(250); 152 | await this.port.setSignals({ 153 | dataTerminalReady: false, 154 | requestToSend: false, 155 | }); 156 | await sleep(250); 157 | await new Promise((resolve) => setTimeout(resolve, 1000)); 158 | } 159 | } 160 | 161 | customElements.define("ewt-console", EwtConsole); 162 | 163 | declare global { 164 | interface HTMLElementTagNameMap { 165 | "ewt-console": EwtConsole; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/flash.ts: -------------------------------------------------------------------------------- 1 | import { Transport, ESPLoader } from "esptool-js"; 2 | import { 3 | Build, 4 | FlashError, 5 | FlashState, 6 | Manifest, 7 | FlashStateType, 8 | } from "./const"; 9 | import { hardReset } from "./util/reset"; 10 | 11 | export const flash = async ( 12 | onEvent: (state: FlashState) => void, 13 | port: SerialPort, 14 | manifestPath: string, 15 | manifest: Manifest, 16 | eraseFirst: boolean, 17 | ) => { 18 | let build: Build | undefined; 19 | let chipFamily: Build["chipFamily"]; 20 | 21 | const fireStateEvent = (stateUpdate: FlashState) => 22 | onEvent({ 23 | ...stateUpdate, 24 | manifest, 25 | build, 26 | chipFamily, 27 | }); 28 | 29 | const transport = new Transport(port); 30 | const esploader = new ESPLoader({ 31 | transport, 32 | baudrate: 115200, 33 | romBaudrate: 115200, 34 | enableTracing: false, 35 | }); 36 | 37 | // For debugging 38 | (window as any).esploader = esploader; 39 | 40 | fireStateEvent({ 41 | state: FlashStateType.INITIALIZING, 42 | message: "Initializing...", 43 | details: { done: false }, 44 | }); 45 | 46 | try { 47 | await esploader.main(); 48 | await esploader.flashId(); 49 | } catch (err: any) { 50 | console.error(err); 51 | fireStateEvent({ 52 | state: FlashStateType.ERROR, 53 | message: 54 | "Failed to initialize. Try resetting your device or holding the BOOT button while clicking INSTALL.", 55 | details: { error: FlashError.FAILED_INITIALIZING, details: err }, 56 | }); 57 | 58 | await hardReset(transport); 59 | await transport.disconnect(); 60 | return; 61 | } 62 | 63 | chipFamily = esploader.chip.CHIP_NAME as any; 64 | 65 | fireStateEvent({ 66 | state: FlashStateType.INITIALIZING, 67 | message: `Initialized. Found ${chipFamily}`, 68 | details: { done: true }, 69 | }); 70 | 71 | build = manifest.builds.find((b) => b.chipFamily === chipFamily); 72 | 73 | if (!build) { 74 | fireStateEvent({ 75 | state: FlashStateType.ERROR, 76 | message: `Your ${chipFamily} board is not supported.`, 77 | details: { error: FlashError.NOT_SUPPORTED, details: chipFamily }, 78 | }); 79 | await hardReset(transport); 80 | await transport.disconnect(); 81 | return; 82 | } 83 | 84 | fireStateEvent({ 85 | state: FlashStateType.PREPARING, 86 | message: "Preparing installation...", 87 | details: { done: false }, 88 | }); 89 | 90 | const manifestURL = new URL(manifestPath, location.toString()).toString(); 91 | const filePromises = build.parts.map(async (part) => { 92 | const url = new URL(part.path, manifestURL).toString(); 93 | const resp = await fetch(url); 94 | if (!resp.ok) { 95 | throw new Error( 96 | `Downlading firmware ${part.path} failed: ${resp.status}`, 97 | ); 98 | } 99 | 100 | const reader = new FileReader(); 101 | const blob = await resp.blob(); 102 | 103 | return new Promise((resolve) => { 104 | reader.addEventListener("load", () => resolve(reader.result as string)); 105 | reader.readAsBinaryString(blob); 106 | }); 107 | }); 108 | 109 | const fileArray: Array<{ data: string; address: number }> = []; 110 | let totalSize = 0; 111 | 112 | for (let part = 0; part < filePromises.length; part++) { 113 | try { 114 | const data = await filePromises[part]; 115 | fileArray.push({ data, address: build.parts[part].offset }); 116 | totalSize += data.length; 117 | } catch (err: any) { 118 | fireStateEvent({ 119 | state: FlashStateType.ERROR, 120 | message: err.message, 121 | details: { 122 | error: FlashError.FAILED_FIRMWARE_DOWNLOAD, 123 | details: err.message, 124 | }, 125 | }); 126 | await hardReset(transport); 127 | await transport.disconnect(); 128 | return; 129 | } 130 | } 131 | 132 | fireStateEvent({ 133 | state: FlashStateType.PREPARING, 134 | message: "Installation prepared", 135 | details: { done: true }, 136 | }); 137 | 138 | if (eraseFirst) { 139 | fireStateEvent({ 140 | state: FlashStateType.ERASING, 141 | message: "Erasing device...", 142 | details: { done: false }, 143 | }); 144 | await esploader.eraseFlash(); 145 | fireStateEvent({ 146 | state: FlashStateType.ERASING, 147 | message: "Device erased", 148 | details: { done: true }, 149 | }); 150 | } 151 | 152 | fireStateEvent({ 153 | state: FlashStateType.WRITING, 154 | message: `Writing progress: 0%`, 155 | details: { 156 | bytesTotal: totalSize, 157 | bytesWritten: 0, 158 | percentage: 0, 159 | }, 160 | }); 161 | 162 | let totalWritten = 0; 163 | 164 | try { 165 | await esploader.writeFlash({ 166 | fileArray, 167 | flashSize: "keep", 168 | flashMode: "keep", 169 | flashFreq: "keep", 170 | eraseAll: false, 171 | compress: true, 172 | // report progress 173 | reportProgress: (fileIndex: number, written: number, total: number) => { 174 | const uncompressedWritten = 175 | (written / total) * fileArray[fileIndex].data.length; 176 | 177 | const newPct = Math.floor( 178 | ((totalWritten + uncompressedWritten) / totalSize) * 100, 179 | ); 180 | 181 | // we're done with this file 182 | if (written === total) { 183 | totalWritten += uncompressedWritten; 184 | return; 185 | } 186 | 187 | fireStateEvent({ 188 | state: FlashStateType.WRITING, 189 | message: `Writing progress: ${newPct}%`, 190 | details: { 191 | bytesTotal: totalSize, 192 | bytesWritten: totalWritten + written, 193 | percentage: newPct, 194 | }, 195 | }); 196 | }, 197 | }); 198 | } catch (err: any) { 199 | fireStateEvent({ 200 | state: FlashStateType.ERROR, 201 | message: err.message, 202 | details: { error: FlashError.WRITE_FAILED, details: err }, 203 | }); 204 | await hardReset(transport); 205 | await transport.disconnect(); 206 | return; 207 | } 208 | 209 | fireStateEvent({ 210 | state: FlashStateType.WRITING, 211 | message: "Writing complete", 212 | details: { 213 | bytesTotal: totalSize, 214 | bytesWritten: totalWritten, 215 | percentage: 100, 216 | }, 217 | }); 218 | 219 | await hardReset(transport); 220 | 221 | console.log("DISCONNECT"); 222 | await transport.disconnect(); 223 | 224 | fireStateEvent({ 225 | state: FlashStateType.FINISHED, 226 | message: "All done!", 227 | }); 228 | }; 229 | -------------------------------------------------------------------------------- /src/no-port-picked/no-port-picked-dialog.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, svg } from "lit"; 2 | import { customElement } from "lit/decorators.js"; 3 | import "../components/ew-dialog"; 4 | import "../components/ew-text-button"; 5 | 6 | import { dialogStyles } from "../styles"; 7 | import { getOperatingSystem } from "../util/get-operating-system"; 8 | 9 | const cloudDownload = svg` 10 | 21 | 22 | 26 | 27 | 28 | `; 29 | 30 | @customElement("ewt-no-port-picked-dialog") 31 | class EwtNoPortPickedDialog extends LitElement { 32 | public doTryAgain?: () => void; 33 | 34 | public render() { 35 | const OS = getOperatingSystem(); 36 | 37 | return html` 38 | 39 |
No port selected
40 |
41 |
42 | If you didn't select a port because you didn't see your device 43 | listed, try the following steps: 44 |
45 |
    46 |
  1. 47 | Make sure that the device is connected to this computer (the one 48 | that runs the browser that shows this website) 49 |
  2. 50 |
  3. 51 | Most devices have a tiny light when it is powered on. If yours has 52 | one, make sure it is on. 53 |
  4. 54 |
  5. 55 | Make sure that the USB cable you use can be used for data and is 56 | not a power-only cable. 57 |
  6. 58 | ${OS === "Linux" 59 | ? html` 60 |
  7. 61 | If you are using a Linux flavor, make sure that your user is 62 | part of the dialout group so it has permission 63 | to access the device. 64 | sudo usermod -a -G dialout YourUserName 67 | You may need to log out & back in or reboot to activate the 68 | new group access. 69 |
  8. 70 | ` 71 | : ""} 72 |
  9. 73 | Make sure you have the right drivers installed. Below are the 74 | drivers for common chips used in ESP devices: 75 |
      76 |
    • 77 | CP2102 drivers: 78 | Windows & Mac 84 |
    • 85 |
    • 86 | CH342, CH343, CH9102 drivers: 87 | Windows, 93 | Mac 99 |
      100 | (download via blue button with ${cloudDownload} icon) 101 |
    • 102 |
    • 103 | CH340, CH341 drivers: 104 | Windows, 110 | Mac 116 |
      117 | (download via blue button with ${cloudDownload} icon) 118 |
    • 119 |
    120 |
  10. 121 |
122 |
123 |
124 | ${this.doTryAgain 125 | ? html` 126 | Cancel 127 | 128 | Try Again 129 | 130 | ` 131 | : html` 132 | Close 133 | `} 134 |
135 |
136 | `; 137 | } 138 | 139 | private tryAgain() { 140 | this.close(); 141 | this.doTryAgain?.(); 142 | } 143 | 144 | private close() { 145 | this.shadowRoot!.querySelector("ew-dialog")!.close(); 146 | } 147 | 148 | private async _handleClose() { 149 | this.parentNode!.removeChild(this); 150 | } 151 | 152 | static styles = [ 153 | dialogStyles, 154 | css` 155 | li + li, 156 | li > ul { 157 | margin-top: 8px; 158 | } 159 | ul, 160 | ol { 161 | margin-bottom: 0; 162 | padding-left: 1.5em; 163 | } 164 | li code.block { 165 | display: block; 166 | margin: 0.5em 0; 167 | } 168 | `, 169 | ]; 170 | } 171 | 172 | declare global { 173 | interface HTMLElementTagNameMap { 174 | "ewt-no-port-picked-dialog": EwtNoPortPickedDialog; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /static/logos/nspanelmanager.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 44 | 46 | 52 | 59 | 66 | 73 | 74 | 80 | NSPANELMANAGER 96 | 97 | 102 | 105 | 110 | 118 | 119 | 124 | 132 | 133 | 136 | 142 | 143 | 146 | 153 | 154 | 155 | 156 | 157 | -------------------------------------------------------------------------------- /src/util/console-color.ts: -------------------------------------------------------------------------------- 1 | interface ConsoleState { 2 | bold: boolean; 3 | italic: boolean; 4 | underline: boolean; 5 | strikethrough: boolean; 6 | foregroundColor: string | null; 7 | backgroundColor: string | null; 8 | carriageReturn: boolean; 9 | lines: string[]; 10 | secret: boolean; 11 | } 12 | 13 | export class ColoredConsole { 14 | public state: ConsoleState = { 15 | bold: false, 16 | italic: false, 17 | underline: false, 18 | strikethrough: false, 19 | foregroundColor: null, 20 | backgroundColor: null, 21 | carriageReturn: false, 22 | lines: [], 23 | secret: false, 24 | }; 25 | 26 | constructor(public targetElement: HTMLElement) {} 27 | 28 | logs(): string { 29 | return this.targetElement.innerText; 30 | } 31 | 32 | processLine(line: string): Element { 33 | // @ts-expect-error 34 | const re = /(?:\033|\\033)(?:\[(.*?)[@-~]|\].*?(?:\007|\033\\))/g; 35 | let i = 0; 36 | 37 | const lineSpan = document.createElement("span"); 38 | lineSpan.classList.add("line"); 39 | 40 | const addSpan = (content: string) => { 41 | if (content === "") return; 42 | 43 | const span = document.createElement("span"); 44 | if (this.state.bold) span.classList.add("log-bold"); 45 | if (this.state.italic) span.classList.add("log-italic"); 46 | if (this.state.underline) span.classList.add("log-underline"); 47 | if (this.state.strikethrough) span.classList.add("log-strikethrough"); 48 | if (this.state.secret) span.classList.add("log-secret"); 49 | if (this.state.foregroundColor !== null) 50 | span.classList.add(`log-fg-${this.state.foregroundColor}`); 51 | if (this.state.backgroundColor !== null) 52 | span.classList.add(`log-bg-${this.state.backgroundColor}`); 53 | span.appendChild(document.createTextNode(content)); 54 | lineSpan.appendChild(span); 55 | 56 | if (this.state.secret) { 57 | const redacted = document.createElement("span"); 58 | redacted.classList.add("log-secret-redacted"); 59 | redacted.appendChild(document.createTextNode("[redacted]")); 60 | lineSpan.appendChild(redacted); 61 | } 62 | }; 63 | 64 | while (true) { 65 | const match = re.exec(line); 66 | if (match === null) break; 67 | 68 | const j = match.index; 69 | addSpan(line.substring(i, j)); 70 | i = j + match[0].length; 71 | 72 | if (match[1] === undefined) continue; 73 | 74 | for (const colorCode of match[1].split(";")) { 75 | switch (parseInt(colorCode)) { 76 | case 0: 77 | // reset 78 | this.state.bold = false; 79 | this.state.italic = false; 80 | this.state.underline = false; 81 | this.state.strikethrough = false; 82 | this.state.foregroundColor = null; 83 | this.state.backgroundColor = null; 84 | this.state.secret = false; 85 | break; 86 | case 1: 87 | this.state.bold = true; 88 | break; 89 | case 3: 90 | this.state.italic = true; 91 | break; 92 | case 4: 93 | this.state.underline = true; 94 | break; 95 | case 5: 96 | this.state.secret = true; 97 | break; 98 | case 6: 99 | this.state.secret = false; 100 | break; 101 | case 9: 102 | this.state.strikethrough = true; 103 | break; 104 | case 22: 105 | this.state.bold = false; 106 | break; 107 | case 23: 108 | this.state.italic = false; 109 | break; 110 | case 24: 111 | this.state.underline = false; 112 | break; 113 | case 29: 114 | this.state.strikethrough = false; 115 | break; 116 | case 30: 117 | this.state.foregroundColor = "black"; 118 | break; 119 | case 31: 120 | this.state.foregroundColor = "red"; 121 | break; 122 | case 32: 123 | this.state.foregroundColor = "green"; 124 | break; 125 | case 33: 126 | this.state.foregroundColor = "yellow"; 127 | break; 128 | case 34: 129 | this.state.foregroundColor = "blue"; 130 | break; 131 | case 35: 132 | this.state.foregroundColor = "magenta"; 133 | break; 134 | case 36: 135 | this.state.foregroundColor = "cyan"; 136 | break; 137 | case 37: 138 | this.state.foregroundColor = "white"; 139 | break; 140 | case 39: 141 | this.state.foregroundColor = null; 142 | break; 143 | case 41: 144 | this.state.backgroundColor = "red"; 145 | break; 146 | case 42: 147 | this.state.backgroundColor = "green"; 148 | break; 149 | case 43: 150 | this.state.backgroundColor = "yellow"; 151 | break; 152 | case 44: 153 | this.state.backgroundColor = "blue"; 154 | break; 155 | case 45: 156 | this.state.backgroundColor = "magenta"; 157 | break; 158 | case 46: 159 | this.state.backgroundColor = "cyan"; 160 | break; 161 | case 47: 162 | this.state.backgroundColor = "white"; 163 | break; 164 | case 40: 165 | case 49: 166 | this.state.backgroundColor = null; 167 | break; 168 | } 169 | } 170 | } 171 | addSpan(line.substring(i)); 172 | return lineSpan; 173 | } 174 | 175 | processLines() { 176 | const atBottom = 177 | this.targetElement.scrollTop > 178 | this.targetElement.scrollHeight - this.targetElement.offsetHeight - 50; 179 | const prevCarriageReturn = this.state.carriageReturn; 180 | const fragment = document.createDocumentFragment(); 181 | 182 | if (this.state.lines.length == 0) { 183 | return; 184 | } 185 | 186 | for (const line of this.state.lines) { 187 | if (this.state.carriageReturn && line !== "\n") { 188 | if (fragment.childElementCount) { 189 | fragment.removeChild(fragment.lastChild!); 190 | } 191 | } 192 | fragment.appendChild(this.processLine(line)); 193 | this.state.carriageReturn = line.includes("\r"); 194 | } 195 | 196 | if (prevCarriageReturn && this.state.lines[0] !== "\n") { 197 | this.targetElement.replaceChild(fragment, this.targetElement.lastChild!); 198 | } else { 199 | this.targetElement.appendChild(fragment); 200 | } 201 | 202 | this.state.lines = []; 203 | 204 | // Keep scroll at bottom 205 | if (atBottom) { 206 | this.targetElement.scrollTop = this.targetElement.scrollHeight; 207 | } 208 | } 209 | 210 | addLine(line: string) { 211 | // Processing of lines is deferred for performance reasons 212 | if (this.state.lines.length == 0) { 213 | setTimeout(() => this.processLines(), 0); 214 | } 215 | this.state.lines.push(line); 216 | } 217 | } 218 | 219 | export const coloredConsoleStyles = ` 220 | .log { 221 | flex: 1; 222 | background-color: #1c1c1c; 223 | font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, 224 | monospace; 225 | font-size: 12px; 226 | padding: 16px; 227 | overflow: auto; 228 | line-height: 1.45; 229 | border-radius: 3px; 230 | white-space: pre-wrap; 231 | overflow-wrap: break-word; 232 | color: #ddd; 233 | } 234 | 235 | .log-bold { 236 | font-weight: bold; 237 | } 238 | .log-italic { 239 | font-style: italic; 240 | } 241 | .log-underline { 242 | text-decoration: underline; 243 | } 244 | .log-strikethrough { 245 | text-decoration: line-through; 246 | } 247 | .log-underline.log-strikethrough { 248 | text-decoration: underline line-through; 249 | } 250 | .log-secret { 251 | -webkit-user-select: none; 252 | -moz-user-select: none; 253 | -ms-user-select: none; 254 | user-select: none; 255 | } 256 | .log-secret-redacted { 257 | opacity: 0; 258 | width: 1px; 259 | font-size: 1px; 260 | } 261 | .log-fg-black { 262 | color: rgb(128, 128, 128); 263 | } 264 | .log-fg-red { 265 | color: rgb(255, 0, 0); 266 | } 267 | .log-fg-green { 268 | color: rgb(0, 255, 0); 269 | } 270 | .log-fg-yellow { 271 | color: rgb(255, 255, 0); 272 | } 273 | .log-fg-blue { 274 | color: rgb(0, 0, 255); 275 | } 276 | .log-fg-magenta { 277 | color: rgb(255, 0, 255); 278 | } 279 | .log-fg-cyan { 280 | color: rgb(0, 255, 255); 281 | } 282 | .log-fg-white { 283 | color: rgb(187, 187, 187); 284 | } 285 | .log-bg-black { 286 | background-color: rgb(0, 0, 0); 287 | } 288 | .log-bg-red { 289 | background-color: rgb(255, 0, 0); 290 | } 291 | .log-bg-green { 292 | background-color: rgb(0, 255, 0); 293 | } 294 | .log-bg-yellow { 295 | background-color: rgb(255, 255, 0); 296 | } 297 | .log-bg-blue { 298 | background-color: rgb(0, 0, 255); 299 | } 300 | .log-bg-magenta { 301 | background-color: rgb(255, 0, 255); 302 | } 303 | .log-bg-cyan { 304 | background-color: rgb(0, 255, 255); 305 | } 306 | .log-bg-white { 307 | background-color: rgb(255, 255, 255); 308 | } 309 | `; 310 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /script/stubgen.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import zlib 3 | import json 4 | 5 | stubs = { 6 | "esp8266": b""" 7 | eNq9PHt/1Da2X8WehCQzJEWyPR6ZxzKZJNNQoIVwSeluehtbtunlVygM6ZJ2YT/79XnJsmeSkL7+yEO2LB2dc3Te0n82z6rzs83bQbF5cp6bk3OtTs6Vmja/9Ml5XcPP/AwetT9Z82Pw7f3mgZGuTcMo+pGeJvHb\ 8 | 06n8d7jLH2SJGwp+ZzSljk7OLbRVEMDcUd78ipue45PzagLzNR1ygK1qhkgX8PZp00rgcxg6hX+0PGkGUWMA5KvnzbgqAAh+hG9mzVRjhExRX13sAZDwL/ebPYfft1P3YLCHv+XLZpKqoEnguwa4LLofNg8FBPqn\ 9 | AarCxd2OuiA8lR4nm7AUWrtJuwiXH/5w2PxqIfwOhpkDljqdvut0gk/iBpoSYb3dgK8s4ZQ7REc8DVBD8N/wwgKoQK9ybDkmMD4TCEdU97853H1AnJRbfpsnrrHVLFfBwA2WK0sUxwaCg0OfCtt1165X4AOwv+qZ\ 10 | sV02pBl6HdtJei95IYQ/12jm3/RGTFaByyB3Fq+MN0jRedPZLGbY22C1P0DMDcCCa8BIbrTM8pao78MIpexI4x4TzXTRQ4VpV+L2+ZPmV+U1dCSNux6YhfLmLxKvUUIjx8Yd74O6IzisDxkMVXlSRBVd0ruX2FNW\ 11 | t5IBVBdC2qcMgO4yVubTAxu5LMcKPaeERNfI28YLpOJ0f45/th/hn/NDx1Nf8X9F8oD/s/YL/q80Gf7X9C5laFhbhUuaPtqQufnbkGAC6DOQfrQ98ROtYRsP8rUBblJaXZQ3gspGeSPjyigH+RPlINqinPFWsbC1\ 12 | Dl8wRcRiq4gZU5b2gUp9bANI0cPBBHo36LBjoonSDAFsQGX3RuEBQ6S5EzzX4UeeWf/Gs+UolkbbThw1/wB6opDw3UKCT7X/9IiGL5eWA71QtA4IXQgEDQ8kioP1oCsfEfiAh4v7w/Hz6HOfv5Nd2Ij4rGIlQP9o\ 13 | +adgyBQvL2IoyxWkyZD6mjC15iA39JlO38s3jMCS3/QEfdY+1dFgFxhsgHId4LDr+GQ8e7oX5YMNZLVGKGgbT6B7wKrJ+LuMvo4j/APKC5WjVoOgBv2qt3bc3FvQY5APuuyk7WDwdI8ZRPlcBBo5Z11lWP9nMfVY\ 14 | 0Krq/rwkZeooaFA2YcFcT4izdcwjW9Uwpqm8uaqKADAlAzYmGindbO7S+mIjatGFdN9koCJaVobLmkRf10xRm5IgQgGUvg/qWJ5X8hBsClOvyXOUG3MSj0rVezL4f7D5jNebkpJANZLSHhJGypagIpMCNmwz0fsW\ 15 | MGO9EbBPLR/OiQIyZuGNOf9VIIyEhp0ZbWpouq2aRAkBPJPO8KCB5Q2NXPeg3eFJAZl5+4nudDPpQ8c+HmCWAcvGbKYBcUsNPXS83cx5Js8UPWt+DKEi81iauvQmXPffxd6k/wV+AXR5JACILfyFPZk+IY5CSkxk\ 16 | mg0moJAiEVJIb/3LsrCpcxo3a5ZNn3V0XrC9Cx8u+h8eHxEoDa5R3+AmUdvUxdpbMO13PK0K0Bw+JvSAjV3pCQ2IbBRjH7B3EchXS3ORwaBLMvbkpZuunvyjeZN3RiOw7YqhQNuh1FuG/Fi8gPlXDLolw8VGss8d\ 17 | juc/CXalr2JuL2oh8KFQ6XDpVUKvbMoklhmU3mi7qRTZdQAMnOg1WkwVtVrZwYWcVbjd8gNK8u9RVI7fsRJIt31AZzIfgqUinCMEAXnZNCz4aJZjlMH3QOuBMI1gwhnqn/HPMLImVV/XsxDtAximjG+CLl4LviYR\ 18 | DKoJ7WISUiHxB/wQ7iPPEREoUvLa2o2EFo2BxZojIqx1hEVN5cQ0D5OB4IaptbkHgxQstGsanTTJTJgJ+CWeNs7jnvDbOph+JLfHR/iHlR7un5j0Bnl1+sleMI0Cej1pWQrViwK1FmzAsLI4zejM4nniaetqfE2s\ 19 | 2PQWYiXyuhjqovuSsYpYkiL+VHqzFZImakFmMbRJO59G2GrFPfheaNqp+huW96jk0NoLeztAiUs69lHudtE3rU5yYyztRXqvq86XMgXMXra2pkYF8pR1r0PkYbvPdeyzzg5NVqs1QOn0H/Ab6bkK5dFRs2nKlGUb\ 20 | zBqd07SuK1iTk8kBjCju3or1gGwitvQoVeFQ6CrfHqLJ8zBGl/3hvthditQAMWdMMKCyADRAPECh1Q/2SNbq7yoRgjHmTN2ivTIt2lHBCrjpspu0Slaw7Qgu4Ph/2UPAph1/LyYR6Gt9TGPbycC3g2qyEBtFC7tp\ 21 | DKZfx/ZJwNYVWAsgYbIRtX10woyTkkRzrp9dmxCapSd4aCqJREPjrF+ygUAwMDchO9RzGS9sI0YVCJESJSgb3BgWqXoGN3qZVdfQDtlfrII14q0KmAw2XalAsBohmUY5tcN00OQSd6cAIRcz4TQaKb0OtGAnX/HZ\ 22 | EQw5nqH0x+HjrZ2D/2M6pGQeUG8Sd/GgtY9RzgVxEJHEqpUTqgeHonOAkv58OKd0O8rXL7b2B8RLzaj5iNkQDYZfWFFluL53xA8wQRGBRFX5I2oaYcoY7TkYPIvCEbzIUVsyPhr8bGYer+dghVKPCC3Y8W1aTBGF\ 23 | 6TvaaHV1FK4X4ejtQzZ48v1XzynIYJKj/AZOcpNts0SY7WtofGgGQtadP6Z3ECaA/ZoD+jUSRI+3XgO0+RbCsnGUD+8+A+n8CfbaDrO3QQm36RvEpMJUrP8NMJLa26KXiIqI/NgTXwSKDCEPHtQH/DUgYkr4hTzV\ 24 | AMHytMLWFmvuai6ifBEilnHbvMU91XjlNjndgzFfo6tdwMYvFuH6nMMSrF1NFK4jcW6EjzkGO6HYVok71YgSPs+IrGjnsQLJI554Tb0C/H0kvwsBSU7BLevMTh+X0CNuIDlq5pzBnN82E05g4x6xmlaEYRN9R/LC\ 25 | qFuk1tGojP85aCydI4hEEpeARVlOYMN+aIWYjhvL5yhMiHfYjGUXMQieA7jHtMcpmhUkwvQmGHNgDk3nXfIFJGyho/yw3VS6nvKuyvW3aRs2VDXGnhW7kJY3NOIgVnUANv+83VlqhWAvG8qEQPlqe88hEP0oIL7i\ 26 | B0V6N+DvO0FWYAkgB/ODwH66JkRB2yJmKZ16I8K8c89HKVlpNSxCm6ooScgi2uFtsTNobSKS9FMRipqwZ20AfnmAAIP2AxDqd0QVlOMqWB8CHwTh7hA0EvkbVbxLVAGNWpfHMwrzrJ9s3h2xtumhLYtk5eAFXrb4\ 27 | ZyGp5brKik9iQaKvGftDXo6WHNGSC1r070ULL4o2JzY49DdlRCArDVjxmYBtDEV2kxgAQMjMelECe32e4M8r9hJh3bJ7OyyAsf/PWquskVYMLECxiioR47r0iAyiFQTLeY0kh/CYs5ERE2Z31eKY8q09ycQnSZjI\ 28 | EoG09j2xTpksLRHIGVK8DYBydC13SbzXZSSJkzoffDUQwAQRmiVFnXpWNIWcQpYB1UdOakXsWoPzTz52UKtTyEEkLxvwaxKG1jOzHaDbuIJeroPQOmDkXFMa3GVRUOACPJ5nq7OfcFktFbLfLxWEJV4T7Mjv6kw0\ 29 | sSN8h00QCJcuu7vH6GaV7LAVsjgnhT0mSjaUBu1c/Az/HggVn9MeQgxOjjlQa9jZsV5QLmYDBvAZXSYiwl87M+e+Br2aLNuW41mmm+y4cFeWoMH1G5q9hLykVU9AoKqXHD7QOPBL9tWwVT8miMDufXIjaFRrkY+d\ 30 | DSYGb+nbs0U4dsr1FVD81fPTnzGOA9yfzcmXAzIQGtBBMHPf6emsAjNIccgmXokSec6aKea9Xm+ISwBICVr5MngJegCjKhAqhkBjXc7gYSTCcUyLWoBF1a6rqPrrWoQxLYoWCJb/hCMYmYvbmNneCwB7gNEJCMdR\ 31 | YkK9penOwItBX4C6fKAuxEJZsgCgXwCdXjN67Pnc3yu/DWAR6IygfNkhJOgCxgXWq3VI1lgNGscIli0HHtswihNPA3a9IHxu0lNM3XBQvURZ1m5W0EK6nPdlCJt7rFrzS1Ur28Xps56ADc/cfEV44+UqW+Je1FXA\ 32 | LDVgSvTRqsALwJAQmaDaDH+CsXd/cjN4/cTKxgHjVgxNv2GKAp0qijEoEjCOJeKOBj7EcOABaqZ1BYlbNbBsnDrZf9+T/ei5hj8gR7IypCyOZ8Binlv1WZFsEmR8t9Xm3RwoW/COKuYKqlQi5VFELtzuy1CGoOU/\ 33 | Ec3+AV9tXkuvo2kXPkRCjFawVjO0Wh/th4c9nd/gsqPiweojdIZrqHQHGBG4T8Jd2xiihqDkkWcmKMPmLKGR8R95gQWE1up9X6yhHFD6Xyu3P5ZNoFmCyzzlWhjYOdFuiuBu4cKElOw5aPRZMI2in/VHRkJFEIwl\ 34 | RsBqmovHUfrx8giQKAWxCiOBNzFC9uNUmeHgceO07gzyLx4z3zRyzMk01uUx40TVL1qu1eSxRgNhZsKxjsIvprcADMg3VWJ2RUN4NyR0Y7o4ai2vSu3RIBhS0/tfUXwyw2T7V7ujIccreJ4h+dQgtfOcc0zVmCMo\ 35 | tYUCF8P7t05Ho5ASfLWdUZarVNMv2TWF6cwLCDMYltmFHa0jUbYRvDOwveJZng0ohrh3lYG/zXo+y5ZE4TZO+EZoC6Yimg6GHHUNzn+uzjmlW5DorTNPKZo2U8z4GORmFpq3AQa181sEWF1PB8Hbd7vHP7ShA5jN\ 36 | TCZ33p4zptUH1IcfoPn2rZ6FaoHfY1Sl8bNsRQY17mHDliPE33IN+EreEuQZV3ZA6ElD5imPvWSRkwOz8BZ8PZi9aBNZzeebJD9qjDEHtJ+ygLYPVofltI1y3pmF2uXiMEvAGDMVOBbMMJh6exnBRPYThaso4lUF\ 37 | w10yJdFmtewJJS66hKTh2VXSbmLbgJ99ZFWIzxSDlnBZi6EXuVoJzAcCRr13wTeMeIUTtsdICkxmIWccCtP4tDQ1IMGaAOkbfABQPqIACLO3v6nfFgOBf/Qh5IIHC90y9G6+heFmwHfATRna2vuzfHsRfkGyGxJ7\ 38 | LlDLZQcNXDsbW15GO2ujkcYrV4Awsu1kmFqv6gZssiGJojwVaZ5uSJ0Jo7/C7hAvASe6tqO1Kdgxit0bctcijDAGmgrkkFOUGoIgSCN0x8H6UNF8vc1taE/9kJnIoMb7FF7BIKT9SJa6p7NP4TU4VA2yt9RoA40q\ 39 | B1MmEqt4TwkETLekNy3afC+pRAf0y1LE6zNMIC2FGhAy9SOwzhTy7J9qID6lLu5drWaFXKj7EBdeSL3M2+D7qtoO9LzudiwhrEBiEG7ysH0Q1gIXqIgk9BBShYVH+vwl0f0UcRz5dI87dKdSA5VpGNYk85Oz5bI9\ 40 | 3I283Dzh/aoLcR8SJDEUWmmsRU2Ck02Fwj4fnHZp/JjwlAHrOlp0zTpn9A3A26QkyMkm5AWSEGx3uwi3KC7xb4JKancWM2HUNqCKhRKGwtng+0/I8nfOrB/t81wgSnuEXwozp1kxhyBipX8itLZReZHFg3yDzHWK\ 41 | rS6uy6RQpZJydnMFZ14U4lmEG1e59B84lNWgY0N/RzjLyOW2EnB0mqim7zM0yora5TQAAvD50EH0g3KmQ7xRHT6lGYyj4+iWs/6AGtnJQt9gLQHWiSH7yt5OnLuBPnDf9WXR1vd7uUyCLXJfOFFQMyP7BLK1zRDw\ 42 | J3pVvvQzsL+21UwZBhs8B7DDE89IAFkuiav1bRg7vAO/76GNt2zWft2HurEAIZJTU60CuMoI020sH3zQ7+07wJwqqO0WEaXxlIZMHi7Dq+05WaVl+pL43qb3EHegOJywUqkoEDC6ql02SYoniTxfE9w89rdURy/N\ 43 | v2AObzvDHGBGQP0K5CUrUPlZ0lE9xQrVQ+lCVj0zT/W46iCOvUW96FdXA7HWyTzT46/UQJ8TwtWd+Gb5WUGjP1MDFX+bBtqluorLyY6GNRQ4KaY8FdoFQykv5OpE68OLREadU7SE1cVah7qkOdi6xKEmbAvXMUqI\ 44 | Kdf4cpQeQ7pIXGhAjYjTRt0wf+C0UZvPr9luJjRPW/Oo651TqFNJGFT1wqARYssLg9YU++SoKwZAsbQi75uGwFgZl0s6bpE89FpwEdMwOVuaafYpWpqhJ6GWTUUinNJIKdgernbGEk10sX6RMaAydRstUWYPk8fT\ 45 | mI0SrNJBoyBiKzTmUEXpqZkLKPIBEPyRa5awJmOCZQ6zJczsI/jrwcXbqeSKExX1mBqqDvoIIs6e+QjCGaaBnj4QBEUegjAxKtopXRJHw0Yc5f8V7PRNptecH2cbvS4lRO6hW3Az9XCjOGeMjIT4jOZdFm1wAEPp\ 46 | m8/nXOVcA1wmqSkt4upKJEpBpwVGtQR2ES/Aq/rVgIuQN0KhdGOZvCrYEeDEJnhGavzhLhUxolSqfl8ETXPgX6UcN/IEqhdBuzQ31thmG1enQ3IKaRbY149dpv43ftTytBe1TLsWUpu/7jp6czYPbkgRbcfHM6xd\ 47 | H62WscC/qr7Evauu5d51FOsBgP0zs6ecEKv/avfuOpyAsEK53gUqtssRf5KKnf+t+jXnOuOW9qdd2l/i4pmui9fVr0bv07B/r3OHtnMhe0aPYoMBpXxDs5qA3TlaY6myhfLjDef5YUfqai7S4/MMMRJjWIk1aeNv\ 48 | V9tj5UXiI/ss8ZFN2PtiaeNLkLz9rKAMaCtERrTB3nSwxxWSiMAi33rHAUsCeuvtL1I9Gd6UQ6Y5OmIsTEipq9uDRT68RyKAiwaprG6Xkr9oNeWIOTzhkfzEEjySIlw0rbafZVgFg7G9u1DBUWKO4dPJAl/YDaoN\ 49 | vzQjy2eUcmcHEaaGDT4W4c3TEbIKH6rK3xJm1jwiAGkajXwgEj0fssJTxc/HvGVEsBS8/+jbgpmB1ak819h02ju6M8AH0R1QomlKeRuIgdepG4pGgJSoGgtsmlTrq+d+fYqEWj9S9Nk4T2XRHlhyEcPKPz8GE8Iu\ 50 | QFUxYXEuccRUvEasWwm8dOHnCdFttrVSrsK0n1dv05Oq2V9bb/MRVuNqbSgl3anTgiyZX5LQsNE6VBnmvEu4rAHztSVysJZ/HuOBtzxxKekDti4kHwlOeOO4N054QCkP7WIMIJkeXVTmcw0KZLFfO/bn5wOlqAO9\ 51 | pbMuqvxanjfXCVxtc6UAJq/zrFhd8NKJZU2knHEiO/jD7q+c5UB1uiUZOamAHdIDquPhMhwUNbUMHS5QIK1lRVvgZ1xkT3phIG+DLFGdPITjBhat8vgCDjLMQabPQaA1f4QpxdjFkJnhU0TZufCTVDlQJpICWXga\ 52 | LmnTkBKLY/0A3AaJgQojGeGQiRPxcd3oR/gHs4jA3y4ZXYRjPucP5MRe4DPgP3z+r+TKeEzJjLlKXrYyHmLBIOGYFQCUnRoptB5/ak8ao/RPHkuyjH+wBxpL5wgLxuNu9KvCfONbJCx5uFh0G7Wl4/AM8AB+BuYY\ 53 | uQDbCb6Y3skPOvc5HS50fZJL3o0veZde8m7SfQewVdw2xeA2rOLLDLA7XQN9ClxcMNYzddrxwCJfj8Gn7WCjjKSqir6EFGytIZyf42mMWWMtLPHVAUbt8dBSLmcvdmgn2vF7yhNql3yefuBzIw3/7f4TviH+YQcY\ 54 | z/lPdriqFQxFK8c80uUbBDBjrcOQp84pmAH0LKOWw0p7LGKZ2ajinFvFJWaYA4yXXFQvnstegpL8Iip3DB7nR+Foa62AwGqJJQZ4EvE5/wMdS8iQ1/lwzaDtXdw6ykfvwOrALZs/C24FRb7+3ckigFTt+O1YjvAB\ 55 | xDMK4RgsLsbTYtMbFN1Br1aOIGkqDyQ6gUIeb7lD1wvE9ojqUhVvMpQmZusejVNEssluaTlGAOCjCyh5Qo7XlC54Jme6JkMQcukd74gfdqhgeD4E84pLHOQUVHshhjzMYBSbojiHY3OS7VdpxfIONgDArrD4Pu8f\ 56 | 2Z4sP8QaJ3xoBBQspftfgcad1vhi+WOrPJ2Kzr6Ew7Tq9w64JIPtfLYsm2l3AM338HxjAD4QODfuXJDrlsL0BZ3+GPApFjOOxGtT/iFUOewXz8GfL84//fj6xfeHj829rZ2WWUGOrUJI5RdpRMT9VqJhoKHQHdN8\ 57 | 7LqPYhkcsvrYUW3tIGKXDqJjTKx0R6fYgRQDPjv83uw84NM3WBuVZa38o7te5KBYJvcEGD6q5o7SwS+s6JVS3exeC6O2eNwKJ3EdGRZF0Hfi1TULDMvH6OpS9gaI31rzcT5kgSjc617ZYKMQL2UI8VKGEC9lCO+T\ 58 | lG4Q6d240r+foy0nhUYrdk7961FOpQy6c5EOCTrvCgRm5ejkDJ5lIRvGYHTWpneVgGXDAgUNFthaur6l1HxlRNar0sTbF+qkMw7ImMZ7XuCdPsgpzp4BLpdYE1e70schR5xrvpmmBZ5ZwBqmJ7CgHKVafX+N0wNy\ 59 | MYLDW+RjNFpCL8ztIdU/D6bU+vEeA0q3F+2yCVB3GV2zO98+ftC5lgFvpDg+W0KYK5GiAhA1WFqSWbr3ZNpeSeN0YOKv2eMKG7HbAQ+MkWojvHBpsE0farxhCQ9D8sq0eNkTwiAmS+zYu8EpLYR8cecupv7295F5\ 60 | jMFd84g1p5wKinblvCqkM+BpYbafxLyZLNT9iWFDNwuYbTneWU+fyfUobprU0czdzgSSQsNhUzxY0IVxLM7XZ1DOPArwefbIbI/WhtvCtMA9q3jykVwQIy9RwIIcsOhiADLx2JwBpzPbPTl7B9v0aSuZMe4Rc1KV\ 61 | w35aPJuEd4alEg84PA8rKe3u6tNDSEAk19YjTgagcbq1DfYHHoeM+BKuquUDrBAGZwgPWqlvwKNT/+BrVKRjR83aSLR4Mjw5wS8P77KOrSFDYaFyqJHJEJE0UIgGlValecrOX93eURUIMv37l5R6eshXS7WcfiZn\ 62 | vJIZRkHUo90HN9xmhb7j4Rjz2MlXg9FasL07PJBau0ElFzL8RuN29JzRomTGwRopx8sEkLrTIzoC5w4Vc821lotJSlaxddHHpHSwS7LXswTKQs7Pyj1t6UXj8M0n7T7trPEqqdoRKq06IfWC3HoblF7HbB60JpO4\ 63 | s3JTkEgacAYzKq0hAwB3Hv4TtYL/ItBe+IrxbfcSMYjfYoEe0sLibSLTxHuGnmAmVzN5JFjarbA1cae6/dleyyR7dcAnvRSjQzbteMUVH5qgQ05Lpc5JPArMddR8xqKkkxgLLJ7gM/BKt1uiM6yNHgN6tNKBBw1u\ 64 | 03K5e8lOY+kiuceQ0ynl/BXex8Fj0C09rIGXbi6qIk86x9j7QFa+0wZCuuIIudIS4suq9WPx7N5nCoUuX7pLJUgWSBHodVn9J5+NfvQbZ37j3G987LKe6d1nl/Xb/n1jxt5ZoSUy3RokpCNK3iBWzAzAFjAjMCVy\ 65 | qM+UDQ2cXYLk2eb8Qx6jettteajsEHrfu/tEHZWye1/zOfuVvJeLhg75ChcsdKvhAsT0pV8/ekgnVzglVUxW3VCWiOpG/hnttjDP7/l0yrnIE/qWbArJpPgN24sxXxODW9UlOUGLRUEuAbx7bFjm9XdSbYS3Tdxi\ 66 | PZevun2sNDJr/IIvsLQcUEIfZfaafQZ3p0q/dGi3vVjMmTj5Pl7teCC32QFo1U57a1XF8hsDZIgAhJZXUKodBjmzK8hk83Y2dDxzqPfH/Ec2FgAohY6ZEIjYAm9gcVf9ZFuYi7Jkbcq43cMmlbXIySg1e/xTSPFv\ 67 | d6jAyKVxasWNQGj4gIKgTwYnC9yTMDhZeAfemXDOQaCHiif6oYysiBwyAZqELvWkwG7Fl2YQdt1FoxM5mrDx9TD8xDsD73zoAGyWAD4QqRieL4lB0nVSfyvXm7rNiBjg0oRuoEJuBsrRZMY7CPLungLn13x7t3WU\ 68 | 3QV5qedTSbZO/x4BC3GcaCZSFd27L/k0NODQepEDP2HuDoMoqJaqIhpMzumNN/iUh3WpsJmYMnj3BU4BX9vBsM0HkZvsTCK8nGvcnpfQNB9em9GKdBm4hIh7JLAXnU9aM8x99lGunas7J2nXsCIqegKMhif5Yzhk\ 69 | UoyHT/Zb8umkPZomqJjghS+KuUYXA3e2Q2kw1Yv9Y4pJNFzIehI6eQAYqjLBJ5PUi2tEq0w1+tl/cNxeO8i9mkVsAfyRDz/Ehi5aAgVFKALYADtjOOtjuUrK7yCzWChQvRyQPrg4JzhrzfBnQSznEChNt/RN5GO3\ 70 | fb25HeBtxz+8P8sXcOexVpPEJJNJkjRvqjdni1/9h6Z5WOZnOV+O3LnIFXff2LO7pSRYLneL+afga4zwdiBU5xOvUYxd439ITNBVtWMubsA+ufdGc+gDGiftv/4XA5Kv+DirXeOULipeHr/TwKzMym5giFNCUcat\ 71 | 5Y3xGhcN/QsRof/4X2ztugunMX9E3Ccg1V6jlIuaL1/GJQusyPJc8SYh56hp3CKdDI83HfJn7sOHPobNaipwMCXlhq29xu+B+/c0rMcspNWp8U8fBf0bdPs5jbjX7juavZOlbgPQT/eMxqK3qXtz686BubAXTexY\ 72 | RR0zr38KpBPT0CuujNa9/rr3Puq141476bXTXtv02rbb1j14dKd/4Dc6Pf27G/Tp5Tcf/6k/+op2dE0euoqnruKxfju9oj25om0ubZ9d0npzSatzh/XKtr20vbhs71z5c919m14LR2fXWHcf8voKKdCDXPcg6V9g\ 73 | rjvjrfmNm36jM+wdv7HnNzqmSYcg73uSpgdn3mvbXruKV+wS/Tfu4r9aCvxRKfFHpcgflTJ/VApd1b7mj1ZtbNPtwAnuPCp+El8lcXd88518EgR0O22VjrtwpZts9vpWcjyJVGLMp/8HHp1i9w==\ 74 | """, 75 | "esp32": b""" 76 | eNqNWntz2zYS/yo063fTG4CkSNDTTiTXle2kd7XTVnE6urkjQbLpTOqxHfWsuMl99sO+CJBS2/uDNgmCi93F7m8f0O8Hq3a9OjiJ6oPlWhl3qeW6y54v19oGD3DTP1TZct3W7qGBaf5NPoPbHXdfuatbrq2KYASo\ 77 | Ju5dVw6GD92fLIpWy3XplmoT95i7a+JXUwq+mtBXRrv/+YCCYwVoO3aMIe4rGFOOZKu8OKqOO2DBjRZuKtDIgA5wqgcES5qmGzeqAqlNxKJ3JhTVcQ7fNyOmHDOOA5hp1O7igt7izOr/mTleHS6ton4notGe4GWE\ 78 | oxbUZUW8mkgqS9rwC7OkyFUdKLgccVgmb+nGj6CqFx82RXEUP7rRBKSJVRTR1mwTR6kp8dsKs26e25ey8qy0TaA4O2arHAk05Gr7mnxpf2+U/9oqtmggIBdOzKKReSM3Sczy5V+D1YIkxkvSVvS2moiCzSntA8yC\ 79 | /zq7FkMs2JBrEwNPKXmVtek1qROJWrH0eOrmar3nxtNg5xTfg1hIIRgc7nvq3jTJ4M0rO9jlG5i1+I3YnqYwXp6a+MXXl/FQuaUKFKfMlO9MoFtcNgufp1O5u6Bh/KbMelJiy7UWpUbo1k7HFW9KKTqeDJGhDO57\ 80 | MCh5i01ox3VyFPgwa7JkSx7MLAESjBe4VLRx2u2rLYlry2P9Rza54S8cl2UdYlZyOXapYAE0/cpzI4uJSvG+AA+Y8+TMS94yVlZwz9aI64euYhF06uBTpAcmCTQBFpT6SATgjXYEWj2Hj0I/Cm3qNtRruVz1ZIbj\ 81 | t8OvVsR0EzBqyDlWA+WwIodOffUFxAuGEfenxh28ya5Syx6YghHfgKP99P3Vcjkj6KevnfO0jCnGnDmF5bwD6GG7JDj6bkL/RVUhSoHP6gxkTAHs6iRiW2TfCmOQsScxWaTNjn44hA9P4iP4d5iBqpyDjbHSDIEe\ 82 | neaOAmVXPb/YRflhfkyaqNi8HLcNY2fVEN6ZAEM9V1/BhiAUJaSOVnZAk01WiTd6GNeCILplPsTjEm934kqjIFfhXRSOJ7+IJ+4w62CT3SamhYqs1GcMcOMgDcggkK0EQoAM7CVymhAbJN4FTADh7EzCXBKGaRzR\ 83 | RwY5zGDX4+NUfTnjaJoc3ZQXbCNowF+ANg1os5Kdnoy5fEZM9OEPlMBzFe9Wwu4I01raAnjf1KySeotKZI5lE0+HtPFboWmYTvEndBqek23O2YysJMmJpG+Jf4fmw8+6jhmeao54wE2X/VG6I/c34YMDpQYxfwqo\ 84 | 8Td2AECufhjCJMjrHvKdHeKh0Wy6QQo28M3cY9u1Dx81Os/V146ibTiayw4FUSak1NljP7lm59tgIR1/+CKmGLuwlE2iH7C71sHXgMRVxbDfbtlAGC+DfKGWb/a8lZJ55MNESCm0WfvHG+zNw7Ic1v6VebwLN+9t\ 85 | +HAXPqzCh3X4ABr9mcGvUb3zwHpv2Y12Kp+1hhmsrrpLklMjqtVejei/2bPl7RsgdNrxlK17eu1rBJS5Eeo/QlSavHYbZNjK84L11NC6OH+Lu/rk9j7YLmTy6RpZmu8FM3Hbpu9J75oRWuodsrG7tdhoAYmSxCG7\ 86 | NQ65j6p7GuxLg8krDG1P9wwgNqglMKVw+1Uj7UbQHzd/z5dntLxw9G8IqcxGbcZsPMVPZwWhasNiNqj5q9V2MU0B4bggvUpUULSVtyDx/B2T0aE64c2hV6W89IwsONdqKR+qmFMErvwdkalrQPnf2RwLTjtB/Pyb\ 87 | 5eonUAx88VJw7kcuMVPvv5C04hoFJQ/WdN8AC6/2YQnQBBS9yWtSCXABnlyijThN1qBJMFz0fRam7Lb4/8SXTUjBbMMC2tdWUGgim/kMA5imLfwzx8Zc1z7/7mJ2SdxyMwByRsCUGnA44+/hQflWApYI2fNRhbWl\ 88 | PANDNYOQMB3Uj+NkFFki4O0fnM4OAgpZ0MMQIxIWAjGksmQitS9xPr7lpVtGpRvJKafvjjEamYSDknb4QnfW0t239A8SzAmTAQQtSZw1RTNFCZCLXjc92n1LkRzQzn2iuQht2t5/b+MKgYyzG/VHAQK+QrjRFDDx\ 89 | 8z4QnELUVC/jAlCj2KO9pBVekktYHXF6iclDKyACi4HFtztyM5MC8/izcW+l7h3RzQZUBjpoIHj9dOkjbLNh4jNB4Fdk4MB52E1yJnwV+xzziBN6/d/tKNyYLSqy+UaXKqGtqityJZtNoQyGkIkQ2xf0EfXGIDtV\ 90 | 7HLbcYeCSs3yNfoFLxkMIh5ZUg+k2pUOEW97/HXc1zaE9eMUUiDYc7DtJvl8IBYk0fnDhrSpiNOPQlBKT2cg8ykHO42TDnBQP1wvV2+u96mGhyCgbfFIFCBWoI0hg/J1+sA32Cs6Axp3Z1GHNxdxvy6k6Nnl9XyY\ 91 | q2i7e3oNGRtAl/QxgGOwJrJT4LSl4rzr/nzRe/IaF+LuABrOCaV1EuY7QCrzoQEG23ZPBvcoinXd3JusUg8DRaHjlQVdyEBOEK7UY/mP7mdJIYhnNJD8Mep4ss3vZfAcVNkhrCXz16yUfB9uunMcFHzpXvuVKoS+\ 92 | edybEDbQQn7A6vHe8YNLPDKxCvFtThFAQYE1EKH0JOsBybmgFidhpWzeaGGVn9Kqxx2pnrj5TqYvV8gQAwRkdxDhoO0zVCeX9iCH7g2F1h0u97q3ovNw+Ne+FkC/lbYM1kPJWFeJKEqH0xRN0cVuMKhlUBNzKhtG\ 93 | rn5iOpboJCjEYUI2nnDBWRrq40HsGPYs9buii0VzCICx2lZLrwitcCvbkLF9atoht0iFtrHESH4KhpQ8wN/0Drw+8r2qOluQKQBwQbCoMAW+O6Nwjm2mihM5E8RfuKASN/rtRpZ2AtH3geMO1A4VR3kHgQfA/X1Q\ 94 | dPZ9sgW1dLp27rVe8dVh23CjFlwcU4Fq9Evppj5KwfOC82Ba+9FQT8gG9fZIjputcpSbciwot3WMVowcBDuyB7jZn8LNnn8/2ivIG0Emlf8QkFCaXxGJR2m3xMRzhTcJAWmt+4SgL4a3kVEQMahtioESbSG7AiKR\ 95 | cJ6ckwnQ/Fte1kDQhg43GRSbBzVqfyM7tPkvgeUCPtbZswtIbPQz1mZo7LRQJQuhAa8Tv9TdikAIUKrl9miFAWNGEA82Cklyq980p+Bin3/YJdIlGEj6CXWRL5d9p/iBIzJjkKO2CvcM0Cd/AX/Wb4B0fMpRAtK9\ 96 | knvFYSFdqif3J5W4Ls4OSaAZt1MqqibgwmzeUAsax/FMRJ0fc8dSOi8TCX7cQYa5Wk/hJjr1QUfEURQW2YyzfkrBERPbGeos6oXhnBMibomddgSTs/DorgkQOxFPHOjrknHUZDJsFhwIsWuH5NZg2PlXYRpAzcie\ 97 | d4YpRNtMkJrDTouZpgHfA9uwkCzX2B/DIxdNLjrGR0f6trfO+QBU989kBdkdIFQ3/kRCqa2dpqBHObZmNXCzzzEDitdJnw598His6CATq5vuTEK12eYjGIB3A7wg/mWp+Q5vVluIP7xaxxzZsxk3AVpc+xNLl/vT\ 98 | YTxGSHwWDzvQ4FlWb/lUyWJ3DuNXCqHyN7i5o2X0hiZyOMgTRTaYBcurEg8wnEbPv/x2SmO6txuEWOBTGtsbbvREVliaX14+4uq3zF73BN2NOyqGrToviCcEuZJT8JIcr+XDR2zyVINmhJO/I9TWxYxT9HbN/oev\ 99 | exN/E0TXakZSde0doaJJ3/SCkGru5GA7qQCEcJLU6ckODLUU0ffkxR5pt+vAmQr2VspJBdg7zNKn5+gybEcf0KYanxYWKN5+5rO+jss2NISud8eDR64AcNp7GAfKiFUoe/BeYXoDeSWCRBuAQpMgV9/JY4aP7+fc\ 100 | iWuH+RN5MeBSi7qOKfRYPvWoub0zKJIq5r8MaqxhuQc9K1funk3gRUyYWOKNinYjwlvAYVxc9gc6RZZr11qP12XcT/lgred49FuFnKvqQMwG4zSwImfOPoSbeI9b6Vh+1P7LWmJAehrWPcQmxoC6jmgadimZQctn\ 101 | kdXkwXcIGtyjvyikngDsQ6/HMwd1xudxkOvAIR3EW82NMLCGUs5gwuODMqn4/GPyTzm82elPRA64G8/NXUVNswNKo8BLW+zZ/bjlyI4+BultJtKzxNhV27JtGNC5QY7Hd6/h+K54gcd3xWExBZnVZUHeUteMVqQq\ 102 | Dnq9th7Flh4huNLSD+SfnUAIQGsup5/6ETsrTxxJ6dD6oaIQ0GIPQmH/Jz/cFzQBGbGUwuLzxCdAFXsGIFij9xb+HNPqnVvO2Uv6EKwUqpdK/8rdSz7XsFJiW0o28N7QsRG1bv6zqUSLobQ2+7DcvWRdpFL0Gzwh\ 103 | hreScdd0bFjB6azhxiXMBDgBp6VMO6IDZ37E1lnkKWKDraIKDk/BRMUtlWvggXiWKL8mCE7hRGdWdDbhXr6wblFtR/gLHv0V7M+hIZSvCk7uOs03nPk1+RG3ncHw4LQB+t6VlZaUYRdCQysub+bBTyjsl1x+MSxL\ 104 | 7wx4t5jzzaCl24idmqMf5tQEF0QwWoUTyqMbbhbQhMN4wdmjaAED8PdbgEw/5wCg/7751uir8eD8U79IwjE1oYrTr/PyrwDzm80JeN93GdjshmdQ/S+1xDr75Jt7XfRjCsrLLChSTsQxcSh8aFfoe+lHSgbwbcvl\ 105 | l8YkeT8m2cyoJtw4i1VHn8m66LOxEO4gjaM14/5M98D/3IlYw9lf8MnItvPeRn6GJaLlg09jz8pQVwfPIvw54L/er6oH+FGgVkU2SZwSM/emvV09fOgHdVbmbrCpVlXw60Humh/wm5BQmqvJJMs+/Q8JS3S9\ 106 | """, 107 | "esp32s2": b""" 108 | eNqNW/9X3DYS/1d2nQBLSO8kr9eW095jSdp9pOn1Ak0pzeNdsWUb0pdysN3Akkv+99N8s2SvSe8HB68sjUaj+fKZkfLfnVW9Xu08G5U7Z2tl3KPgOT9baxv8oBf+Udi9s3VT77s+vjk9gD9j96FwT3O2tmoELUAy\ 109 | dt+avNM8cf8kI/eaJ+5xU9Wxa0ndMwtng4EzGmi0+5t2iDhWgLyjYAxxX0CbWjlyKlhOGTXAhWvNXFegkQAdYFZ3CObUTVeuteXhzWi+/0bN92mFjlsYU/UYcQy4WQ28qccnh/QVexb/T8/ujPA8dbPCX/4TPEYY\ 110 | qUEyVlZSEiVlaeF+Pl4UMlMGssx7jOXxJb34FpTqyf3mChzFT641hkVECvYRdmFzFfDMid9amHX93BbkhWelrgJ52T5beW9BXa6G5+RH+3ejgtGK9RcIyIMdk5Hs81hYiSNeXPoCtBOWYfwy6oK+FjORrnlOmwC9\ 111 | 4K9OjkThMlbY0kTA0JQMyNrpEckSiVrR6Gju+mq95dqnwbYpfoc1IYWgsbvpU/elijtfjm1ni0+h18kHYns+hfb8uYm+f/Ey6ko2V4HUYDdg+qbaF0UO5JyEv+dzeTsE4jwGbJ6piS6XWuQ6Qgt2Yq54p3IR86zr\ 112 | BPLgvbX7nLfYhHpcxruB6bIwc9bkTs8crN/4NcMDe6fd1tqclmC5rR1k41Me4bjMy9A9xS/7JhVMgKpfeG5kMhEpvmdgAQvunPiV1+wWC3hnhcT5Q1Ox6GvKYCjSA60EmuAWlPpEBOCLdgRqvYBBoR2FanUVyjU/\ 113 | W7Vkuu1X3VErYroKGDVkH6uOcFiQfaNGzUBrBI1LYD49551AG3c/yiT4kSvqQ+oVuOHcRmyE064bDANGEdMjmyQG3wkqKdnpYP90gGY452zze7jkRkmALds3Y3WworF3lKEWWUO7UXA4LIOVm8D/dxbCAcP0gp9O\ 114 | JMztz/eCqZkSu2wTC6VmgnFN+6AqQdlY0vli5rl2s149vAHEw6Q7bVEBg2Ox4EgUr4k4ukF053lXYLKTXdG7Og2CIwStguJKa5+wDBAeChnNA0xwF1zsCN5ASy06ZTeomT9x/xbkdUBs4IdhkaDeaJfMCOpIvBmX\ 115 | cMvqL+sAPa3IY5rNtnJgU47Plqx/7ktV1n6/20a7IM3rcNCOaOlwbMt7oytekhkKt8Tes3hzqWgO/FuXURv8ea4mGSblJ8aVzrwthb0LI1ZRJPKmlHiFmi0F1+zUp2AnB/vbkd30K9exYJOwPewp9ora4J6yIjyB\ 116 | Kptt7p3RkZecAA+xgAcdwXSTEOgq+GGQvGPqKtw40KnpNusxhGtmStm9uPiybiHuYf4pXmBkQfVBLhuaVpnAXoJA9qKLxmQ7rL0JjPQA4exp8npqGR6BBKen4E3e/vT67OyA8DetBmJ86yq+dZOkHBsR/jymXUId\ 117 | iEkJbb6JH4F5nQAnU4ChZTxi0cYDe2SfRaxdye6bCQx8Fu3Cn0kCWuHQT0cvryktaQoEPQESnwvWKVnnCn5LCw/NLXIQeDiNOGcE4pwKl0Wfy3/ijoP4YNGgeBIrNZmOBBrBL1rgHiBFmwXYKPYIoYkHsxB0YGa0\ 118 | EatLng/NhXW1bgaCmnrMKLQfsEFTmw0vpglJIYds57SsQ+gAi7IHkojEYf6ELXrXILqAjLGInkzVNwfi0ndP88MWmX+FoQVEWMh2z4YTBZ+/noY/3h/zJhu75A02VtqU/YmymvGYNgN0WKuH9hMVvsVTR96hkUN5\ 119 | /cKyGSeBd3oAQzT2ie8sDmmDhQ2f8n1E0P7EUtxGyMhxuAxGg/lDWHvIheC4PEhTSj8GFL5WJH3ym2k3/1IK98IObcT7UPiX4Y/r8Mcq/LEOfzBkQj80Lnp5binWh1nTc9TKgx+8SVISUHo5ADZZCrr7lQsLMfrH\ 120 | 5gzKJOkWwYHNGP+UUM3TvvCOxH6ct7QsWl38DHB39ovbhYyI2PTrAOuktACZqRtt3O+SCYq31ugpPh6xUSTI+mIrGIFJwPxPiq6aHYsEKVKo67UoZNZVSDsdcqjgn24oWLUAa3aMPvrjDTt7K84endM1LHfEtDU0\ 121 | Sybj/lYSeJgB4glqTil7zHIDLn6MPn6bkVuoeKEV8vp6NbxQk0kinvrUmaHwFWz14j0vuQ4FC18mXpi26jNywkC8JuBdBFBKpe+JTFmSrZia9TbDQUsw+O/Ort6SLhTxK4FOP3PFaurZBJstGp4oo1Bos+Y74ON4\ 122 | G+YBcQCWiX8huUADWG9eB74n7Vp8ofrrAS1vfAyRiNuavuLBEu9QT2sRmwavRzB9SjCdthRdQOFolwMWROhvFCh4/VcQmeVY7f/r8OAlIEtChJ+Ihornh9+gZ6goUrmGIPcx55ToYMUCv/iqp5nP93vloYHakpPC\ 123 | jrROYM5+vc6wnZhO3Jl3KmSD6/H5Jf7QSVCCFa0VzhqPg00wpgzKNmeEAnC2KkycizBxrji5PhU0DWpRJ8Jlcs3NSn2QyGikVAM/1EtB4WbW4vGvpWsrO2wmWu9vhKSVN5Ps4tv6VvqqO37Lk9uWA5LdZUsz5fwN\ 124 | smMBlmDSEcLa54w3xBduxNexqHBg3D6KPQeAp15FGSCpDNS3lhlekd5aPWq1uiYbc7Q+g/TH+O8BAZGmefKoW3EeP4LPLzm9hTUggTaFavZKwBpTardS8KvZi8VsVLMA+dabSX5uvyHD9bm3SCnsWgZO1sZeaJyY\ 125 | rbqu8Utlk7zYZASpJx66t9GleKBkwt8BmPdpdcZvpGQH7CR7Zw6vIw93d9nEOllWGSSF6A3wecvRpxqaBlHnMXlJ0JZwPocIBqYEWmZgSTiHGdDMQm8sJSZ9hfwAXLJN5lC0BZiFmUdbfh6RiRtOZkEDcJqBvW8C\ 126 | N49oiHfZBu2lnjDfMa9YhSpV2AHuaezBNgx8Mj0GN7O3oDxuqDZSsuPnxZZC6fwcIGB6y2g5UDfuGR0Kkd8H1AVkHgDDvBnGVZvtN+TlmoZLpTzbJYMkTZkqVeUZVHKXd9QFyeYPTLex3bBhksbmA6WGQAemLMSY\ 127 | Uo/uF9xtibhp+/UevpYHwGfJ3gNLCBNsg8XER2er+6NtKr0DBW2zO6KhKy6VZLIzMH665JdYRKCvm1EDW6wuo47A0ndH3eRA2+3SzVezg2Org40ipwo0am8ARrzitAzn5FwYoKJLaIsL6Hx+7mvoZUoZdVheAtCS\ 128 | 2269C/KeImyfeWQEXFdt0O3IDsDtHcsrJsRWYDHpIvsRu8cL8ploZentqGl7fpDGBVZhtiUTOJU68zaGgAUTwVRfNSfBRFhzWESBpw95gd3KK+IFZ7gNhiLaWdC2YnkpXEHuYbHDmS3JBUlUiuA6l30MZ1V4Wuam\ 129 | 3GtI+MTKkfQFDbrIvIdt5L3piJHngTVItgdjdXeBb9svOx0WbtoEnOIeUlhyYTPuTDRtBdRIB4Uq/0h+avyZMLmkT27aIQeL1/Ip8aL7Iai5y0QN7G2R+y86g0zCQPUZq3o1uIKDAe8BlUVE7mtw7wuyIGJrm9Od\ 130 | REjSZpG/LrF2vkRofg3sjvyhU5mccAgCUyBfeM3njXhwVHDeZDz6xNM3qNwYvXGycfIMsOiSsz9I6IuUz9EsIGeD7lx5QnTydUKlwKZeBMcs/DQIdP62MdMTSvYR9tfij+9oMte45OyT5r8zhGvRmU27LPBatgbX\ 131 | km+u5YQyLMdsE6iEbTeB7cnE4e4vTsPAt81CB6eR/hrqAn8hTf/EGAXr9yYqPTyrA0dVqFuK9zZdBwwpHdBq5W7k0L0u+aS7aVGfrCS+CAcuhYljZEICTO1jnEQeZCRHRv7w55/EC/gFo2TO9tyo/vL8C4T769hP\ 132 | f31LkoW5KwAsOSL74gMVu+R4C8IsQBqTg45U6d79NhHH0+LpZzyITP8d7knxn4AElywxcrV8peS48lxEg55ofQIxT46UGsUx0QbLt+r8HiTDGwi1IdM7QYF4Dt3dsyJjaEunCmlmfydR5RIRZ0F05JwWOMOQju+j\ 133 | oEPq16Xa6Cnn52HHzIdXpaXsPArW1J5N6fM17bbBWKaKEzZ9kZVpBciCNQxMpIzdlovEN6IAMz4WQAvYlwOsBYl68Q8fFky4KDSei3DoMzlMXoJ7BS0AFbKQGYpBBRW3Hnp0/F4Jzm3UR7Gv3rkam+yhTNkeJkVd\ 134 | 8G2nA9g/DWvmfUGowHZVuodwKlrHHVSXfiYKjhLWJJpDjtchsgpIwpDHG8y3pjbGTeT8BoKQOV5HjEDgoAOcX1PfB8rE16v81TECcJK5yt7Uxgd+iEzeVP7gBagL0iSQTwYqP71GYN03vPSEEhmEa7V8zbkigYeb\ 135 | /3jF6o9n2sHayXL5to5ugosMeVFeNq/g67uzK2YJUpgCvIv6zGqf5XRzCTQY6pY11/yUvee5QeVnXvI640NxjWUz8HT1mkEU71ig9lyMFGGD1DHjz728dcohKHf+60rke81XYRoEDxaQKGetDZQXEG3RdZktbN+6\ 136 | hH9PACQ3AagVs1Z8a0SnLy7wrE0kZVDdVAtvMcFU24IR8ZIDaE36aBCe2fSuxVzlIKybe5BZc7mzC990+tsmxoIBI4a1Uo7tJZhQCqvarFIgN2wXVoVQ1TG5Ra0iZ94Csdu+5Y4vqCXXl520lXN0QmlwNJlAkRTy\ 137 | c3K0mK2P9tCVcrzAmnh2TrUfi6WN1UAiG5NJ4a2doUwXzKOadZde4T02PJbkkFvGXJt13qn+HKQ42o8sH8q2IEXHbKssR8xuyXrFrGuzVfmjdazv1F/OF2E1G573hqv5QN5yvd4YDEoN57WQXpQSwzK63YG2h5VX\ 138 | OXLW/mZcjfenpv6WSk1nqztcWJCsJkF0sENZOHjButp6OnAETSNBGjYd+c3TuD9vBorY6fftgRieRf8CZ9HZ73gWnU2yc7xD9g4LBz8OFPNSX/9HgyCZBjE7ZuRLof9uVApDS66UKX/NRaXj0DiwsDjmi4N4LxfP\ 139 | PJYFufca60sKS6rpZKsls8OHWJgN517MhMro3NBqkR6BsTGMwsXxzRuXnXCLHFAgsuKaJpYCDJ01KRYxtkHk12ZTTjmG7NJswebdhPkS6a/Fq2rwVbKEUq4RF1ie5NMR6AsuClwvpgcApMo8uHEMljMbeaoAIPBU\ 140 | JuYrNiLvyl/hMVy7wYRKtzUfcTR5jKdCxeynnpqi6KCMaCYJnSxA7oGwrNH+WgBMU6UTPpcClWw4TlEFgZGnzd69Ce9kyLneSz7cY8SWNd4naEZyeRP5aOUpSGO+i4Q/wsBJdHJMp0/F7OvgVsKMcKBzyVfDtVA9\ 141 | G1L/Vd+rT+KALVDaWXiNqXWVXCjr0cN3KVtDntKPGMMXlXI5G20hPZfdjGQE4Pps7G9zmLZu1l5bgBGfqJSKX2vOBTUi7+2Il5X+xQ0utftI5sXbQZEQbhAmZ8G+VJh1yzUeYg17f8WHo8Fc7RyVXPaWpaWdoZFn\ 142 | pSurnacj/P8Fv/25Kpbwvwy0yqa5mqVp4r7UV6vlfduYzXTqGqtiVfT+O0JT7e/wlw6hNI6VSj7/D3TrM/g=\ 143 | """, 144 | } 145 | 146 | for key, stub in stubs.items(): 147 | code = eval(zlib.decompress(base64.b64decode(stub))) 148 | print("Processing " + key) 149 | print("Text size:", str(len(code["text"])) + " bytes") 150 | print("Data size:", str(len(code["data"])) + " bytes") 151 | 152 | print(code["text"]) 153 | print(base64.b64encode(code["text"])) 154 | code["text"] = base64.b64encode(code["text"]).decode("utf-8") 155 | code["data"] = base64.b64encode(code["data"]).decode("utf-8") 156 | 157 | jsondata = json.dumps(code, indent=2) 158 | 159 | f = open(f"src/vendor/esptool/stubs/{key}.json", "w+") 160 | f.write(jsondata) 161 | f.close() 162 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ESP Web Tools 6 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | 23 | 27 | 28 | 29 | 33 | 37 | 38 | 135 | 139 | 147 | 148 | 149 |
150 |

ESP Web Tools

151 |

152 | User friendly tools to manage ESP8266 and ESP32 devices in the browser: 153 |

154 |
    155 |
  • Install & update firmware
  • 156 |
  • Connect device to the Wi-Fi network
  • 157 |
  • Visit the device's hosted web interface
  • 158 |
  • Access logs and send terminal commands
  • 159 |
  • 160 | Add devices to 161 | Home Assistant 162 |
  • 163 |
164 |
165 | 169 |
170 | 171 |

Try a live demo

172 |

173 | This demo will install 174 | ESPHome. To get started, connect an ESP 175 | device to your computer and hit the button: 176 |

177 | 180 | 181 | The demo is not available because your browser does not support Web 182 | Serial. Open this page in Google Chrome or Microsoft Edge instead. 187 | 188 | 189 | 190 |

Products using ESP Web Tools

191 | 317 | 318 |

How it works

319 |

320 | ESP Web Tools works by combining 321 | Web Serial, Improv Wi-Fi (optional), 324 | and a manifest which describes the firmware. ESP Web Tools detects the 325 | chipset of the connected ESP device and automatically selects the right 326 | firmware variant from the manifest. 327 |

328 |

329 | Web Serial is available in Google Chrome and Microsoft Edge 330 | browsers. Android support should be possible but has not been implemented yet. 333 |

334 | 335 |

Configuring Wi-Fi

336 |

337 | ESP Web Tools supports the 338 | Improv Wi-Fi serial standard. This is an open standard to allow configuring Wi-Fi via the serial 341 | port. 342 |

343 |

344 | If the firmware supports Improv, a user will be asked to connect the 345 | device to the network after installing the firmware. Once connected, the 346 | device can send the user to a URL to finish configuration. For example, 347 | this can be a link to the device's IP address where it serves a local 348 | UI. 349 |

350 |

351 | At any time in the future a user can use ESP Web Tools to find the 352 | device link or to reconfigure the Wi-Fi settings without doing a 353 | reinstall. 354 |

355 |

356 | Screenshot showing ESP Web Tools dialog offering visting the device, adding it to Home Assistant, change Wi-Fi, show logs and console and reset data. 360 | Screenshot showing the ESP Web Tools interface 361 |

362 | 363 |

Viewing logs & sending commands

364 |

365 | ESP Web Tools allows users to open a serial console to see the logs and 366 | send commands. 367 |

368 |

369 | Screenshot showing ESP Web Tools dialog with a console showing ESPHome logs and a terminal prompt to sent commands. 373 | Screenshot showing the ESP Web Tools logs & console 374 |

375 | 376 |

Adding ESP Web Tools to your website

377 |

378 | To add this to your own website, you need to include the ESP Web Tools 379 | JavaScript files on your website, create a manifest file and add the ESP 380 | Web Tools button HTML. 381 |

382 |

383 | Click here to see a full example. 386 |

387 |

388 | Step 1: Load ESP Web Tools JavaScript on your website by adding 389 | the following HTML snippet. 390 |

391 |
392 | <script
393 |   type="module"
394 |   src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js?module"
395 | ></script>
397 |

398 | (If you prefer to locally host the JavaScript, 399 | download it here) 402 |

403 |

404 | Step 2: Find a place on your page where you want the button to 405 | appear and include the following bit of HTML. Update the 406 | manifest attribute to point at your manifest file. 407 |

408 |
409 | <esp-web-install-button
410 |   manifest="https://firmware.esphome.io/esp-web-tools/manifest.json"
411 | ></esp-web-install-button>
413 |

414 | Note: ESP Web Tools requires that your website is served over 415 | https:// to work. This is a Web Serial security 416 | requirement. 417 |

418 |

419 | If your manifest or the firmware files are hosted on another server, 420 | make sure you configure 421 | the CORS-headers 424 | such that your website is allowed to fetch those files by adding the 425 | header 426 | Access-Control-Allow-Origin: https://domain-of-your-website.com. 429 |

430 | 431 |

432 | ESP Web Tools can also be integrated in your projects by installing it 433 | via NPM. 434 |

435 |

Preparing your firmware

436 |

437 | If you have ESP32 firmware and are using ESP-IDF framework v4 or later, 438 | you will need to create a merged version of your firmware before being 439 | able to use it with ESP Web Tools. If you use ESP8266 or ESP32 with 440 | ESP-IDF v3 or earlier, you can skip this section. 441 |

442 |

443 | ESP32 firmware is split into 4 different files. When these files are 444 | installed using the command-line tool esptool, it will 445 | patch flash frequency, flash size and flash mode to match the target 446 | device. ESP Web Tools is not able to do this on the fly, so you will 447 | need to use esptool to create the single binary file and 448 | use that with ESP Web Tools. 449 |

450 |

451 | Create a single binary using esptool with the following 452 | command: 453 |

454 |
455 | esptool --chip esp32 merge_bin \
456 |   -o merged-firmware.bin \
457 |   --flash_mode dio \
458 |   --flash_freq 40m \
459 |   --flash_size 4MB \
460 |   0x1000 bootloader.bin \
461 |   0x8000 partitions.bin \
462 |   0xe000 boot.bin \
463 |   0x10000 your_app.bin
465 |

466 | If your memory type is opi_opi or opi_qspi, 467 | set your flash mode to be dout. Else, if your flash mode is 468 | qio or qout, override your flash mode to be 469 | dio. 470 |

471 |

Creating your manifest

472 |

473 | Manifests describe the firmware that you want to offer the user to 474 | install. It allows specifying different builds for the different types 475 | of ESP devices. Current supported chip families are 476 | ESP8266, ESP32, ESP32-C2, 477 | ESP32-C3, ESP32-C6, ESP32-H2, 478 | ESP32-S2 and ESP32-S3. The correct build will 479 | be automatically selected based on the type of the connected ESP device. 480 |

481 |
482 | {
483 |   "name": "ESPHome",
484 |   "version": "2021.11.0",
485 |   "home_assistant_domain": "esphome",
486 |   "funding_url": "https://esphome.io/guides/supporters.html",
487 |   "new_install_prompt_erase": false,
488 |   "builds": [
489 |     {
490 |       "chipFamily": "ESP32",
491 |       "parts": [
492 |         { "path": "merged-firmware.bin", "offset": 0 },
493 |       ]
494 |     },
495 |     {
496 |       "chipFamily": "ESP8266",
497 |       "parts": [
498 |         { "path": "esp8266.bin", "offset": 0 }
499 |       ]
500 |     }
501 |   ]
502 | }
504 |

505 | Each build contains a list of parts to be installed to the ESP device. 506 | Each part consists of a path to the file and an offset on the flash 507 | where it should be installed. Part paths are resolved relative to the 508 | path of the manifest, but can also be URLs to other hosts. 509 |

510 |

511 | If your firmware is supported by Home Assistant, you can add the 512 | optional key home_assistant_domain. If present, ESP Web 513 | Tools will link the user to add this device to Home Assistant. 514 |

515 |

516 | By default a new installation will erase all data before installation. 517 | If you want to leave this choice to the user, set the optional manifest 518 | key 519 | new_install_prompt_erase to true. ESP Web 520 | Tools offers users a new installation if it is unable to detect the 521 | current firmware of the device (via Improv Serial) or if the detected 522 | firmware does not match the name specififed in the manifest. 523 |

524 |

525 | When a firmware is first installed on a device, it might need to do some 526 | time consuming tasks like initializing the file system. By default ESP 527 | Web Tools will wait 10 seconds to receive an Improv Serial response to 528 | indicate that the boot is completed. You can increase this timeout by 529 | setting the optional manifest key 530 | new_install_improv_wait_time to the number of seconds to 531 | wait. Set to 0 to disable Improv Serial detection. 532 |

533 |

534 | If your product accepts donations you can add 535 | funding_url to your manifest. This allows you to link to 536 | your page explaining the user how they can fund development. This link 537 | is visible in the ESP Web Tools menu when connected to a device running 538 | your firmware (as detected via Improv). 539 |

540 |

541 | ESP Web Tools allows you to provide your own check if the device is 542 | running the same firmware as specified in the manifest. This check can 543 | be setting the overrides property on 544 | <esp-web-install-button>. The value is an object 545 | containing a 546 | checkSameFirmware(manifest, improvInfo) function. The 547 | manifest parameter is your manifest and 548 | improvInfo is the information returned from Improv: 549 | { name, firmware, version, chipFamily }. This check is only 550 | called if the device firmware was detected via Improv. 551 |

552 |
553 | const button = document.querySelector('esp-web-install-button');
554 | button.overrides = {
555 |   checkSameFirmware(manifest, improvInfo) {
556 |     const manifestFirmware = manifest.name.toLowerCase();
557 |     const deviceFirmware = improvInfo.firmware.toLowerCase();
558 |     return manifestFirmware.includes(deviceFirmware);
559 |   }
560 | };
562 | 563 |

Generating a manifest dynamically & version management

564 |

565 | Alternatively to a static manifest JSON file, you can generate a Blob 566 | URL of a JSON object using 567 | URL.createObjectURL 571 | to use as the manifest url. This can be useful in situations where you 572 | have many firmware bin files, e.g. previous software versions, where you 573 | don't want to have a different static manifest json file for each bin. 574 | If you are hosting on github.io, this can be paired nicely with 575 | github's REST API 579 | to view all the bin files inside a folder. 580 |

581 |
582 | const manifest = {
583 |     "name": name,
584 |     "version": version,
585 |     "funding_url": funding_url,
586 |     "new_install_prompt_erase": true,
587 |     "builds": [
588 |         {
589 |             "chipFamily": "ESP32",
590 |             "improv": false,
591 |             "parts": [
592 |                 { "path": dependenciesDir+"bootloader.bin", "offset": 4096 },
593 |                 { "path": dependenciesDir+"partitions.bin", "offset": 32768 },
594 |                 { "path": dependenciesDir+"boot_app0.bin", "offset": 57344 },
595 |                 { "path": firmwareFile, "offset": 65536 }
596 |             ]
597 |         }
598 |     ]
599 | }
600 | 
601 | const json = JSON.stringify(manifest);
602 | const blob = new Blob([json], {type: "application/json"});
603 | document.querySelector("esp-web-install-button").manifest = URL.createObjectURL(blob);
604 |       
605 | 606 |

Customizing the look and feel

607 |

608 | You can change the colors of the default UI elements with CSS custom 609 | properties (variables), the following variables are available: 610 |

611 |
    612 |
  • --esp-tools-button-color
  • 613 |
  • --esp-tools-button-text-color
  • 614 |
  • --esp-tools-button-border-radius
  • 615 |
616 |

There are also some attributes that can be used for styling:

617 | 618 | 619 | 620 | 621 | 622 | 623 | 626 | 627 | 628 |
install-supportedAdded if installing firmware is supported
624 | install-unsupported 625 | Added if installing firmware is not supported
629 |

Replace the button and message with a custom one

630 |

631 | You can replace both the activation button and the message that is shown 632 | when the user uses an unsupported browser or non-secure context with 633 | your own elements. This can be done using the activate, 634 | unsupported and not-allowed slots: 635 |

636 |
637 | <esp-web-install-button
638 |   manifest="https://firmware.esphome.io/esp-web-tools/manifest.json"
639 | >
640 |   <button slot="activate">Custom install button</button>
641 |   <span slot="unsupported">Ah snap, your browser doesn't work!</span>
642 |   <span slot="not-allowed">Ah snap, you are not allowed to use this on HTTP!</span>
643 | </esp-web-install-button>
644 |     
646 | 647 |

Why we created ESP Web Tools

648 |
649 | 654 |
655 | 656 | 670 |
671 | 684 | 685 | 686 | -------------------------------------------------------------------------------- /src/install-dialog.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, PropertyValues, css, TemplateResult } from "lit"; 2 | import { state } from "lit/decorators.js"; 3 | import "./components/ew-text-button"; 4 | import "./components/ew-list"; 5 | import "./components/ew-list-item"; 6 | import "./components/ew-divider"; 7 | import "./components/ew-checkbox"; 8 | import "./components/ewt-console"; 9 | import "./components/ew-dialog"; 10 | import "./components/ew-icon-button"; 11 | import "./components/ew-filled-text-field"; 12 | import type { EwFilledTextField } from "./components/ew-filled-text-field"; 13 | import "./components/ew-filled-select"; 14 | import "./components/ew-select-option"; 15 | import "./pages/ewt-page-progress"; 16 | import "./pages/ewt-page-message"; 17 | import { 18 | closeIcon, 19 | listItemConsole, 20 | listItemEraseUserData, 21 | listItemFundDevelopment, 22 | listItemHomeAssistant, 23 | listItemInstallIcon, 24 | listItemVisitDevice, 25 | listItemWifi, 26 | refreshIcon, 27 | } from "./components/svg"; 28 | import { Logger, Manifest, FlashStateType, FlashState } from "./const.js"; 29 | import { ImprovSerial, Ssid } from "improv-wifi-serial-sdk/dist/serial"; 30 | import { 31 | ImprovSerialCurrentState, 32 | ImprovSerialErrorState, 33 | PortNotReady, 34 | } from "improv-wifi-serial-sdk/dist/const"; 35 | import { flash } from "./flash"; 36 | import { textDownload } from "./util/file-download"; 37 | import { fireEvent } from "./util/fire-event"; 38 | import { sleep } from "./util/sleep"; 39 | import { downloadManifest } from "./util/manifest"; 40 | import { dialogStyles } from "./styles"; 41 | import { version } from "./version"; 42 | import type { EwFilledSelect } from "./components/ew-filled-select"; 43 | 44 | console.log( 45 | `ESP Web Tools ${version} by Open Home Foundation; https://esphome.github.io/esp-web-tools/`, 46 | ); 47 | 48 | const ERROR_ICON = "⚠️"; 49 | const OK_ICON = "🎉"; 50 | 51 | export class EwtInstallDialog extends LitElement { 52 | public port!: SerialPort; 53 | 54 | public manifestPath!: string; 55 | 56 | public logger: Logger = console; 57 | 58 | public overrides?: { 59 | checkSameFirmware?: ( 60 | manifest: Manifest, 61 | deviceImprov: ImprovSerial["info"], 62 | ) => boolean; 63 | }; 64 | 65 | private _manifest!: Manifest; 66 | 67 | private _info?: ImprovSerial["info"]; 68 | 69 | // null = NOT_SUPPORTED 70 | @state() private _client?: ImprovSerial | null; 71 | 72 | @state() private _state: 73 | | "ERROR" 74 | | "DASHBOARD" 75 | | "PROVISION" 76 | | "INSTALL" 77 | | "ASK_ERASE" 78 | | "LOGS" = "DASHBOARD"; 79 | 80 | @state() private _installErase = false; 81 | @state() private _installConfirmed = false; 82 | @state() private _installState?: FlashState; 83 | 84 | @state() private _provisionForce = false; 85 | private _wasProvisioned = false; 86 | 87 | @state() private _error?: string; 88 | 89 | @state() private _busy = false; 90 | 91 | // undefined = not loaded 92 | // null = not available 93 | @state() private _ssids?: Ssid[] | null; 94 | 95 | // Name of Ssid. Null = other 96 | @state() private _selectedSsid: string | null = null; 97 | 98 | private _bodyOverflow: string | null = null; 99 | 100 | protected render() { 101 | if (!this.port) { 102 | return html``; 103 | } 104 | let heading: string | undefined; 105 | let content: TemplateResult; 106 | let allowClosing = false; 107 | 108 | // During installation phase we temporarily remove the client 109 | if ( 110 | this._client === undefined && 111 | this._state !== "INSTALL" && 112 | this._state !== "LOGS" 113 | ) { 114 | if (this._error) { 115 | [heading, content] = this._renderError(this._error); 116 | } else { 117 | content = this._renderProgress("Connecting"); 118 | } 119 | } else if (this._state === "INSTALL") { 120 | [heading, content, allowClosing] = this._renderInstall(); 121 | } else if (this._state === "ASK_ERASE") { 122 | [heading, content] = this._renderAskErase(); 123 | } else if (this._state === "ERROR") { 124 | [heading, content] = this._renderError(this._error!); 125 | } else if (this._state === "DASHBOARD") { 126 | [heading, content, allowClosing] = this._client 127 | ? this._renderDashboard() 128 | : this._renderDashboardNoImprov(); 129 | } else if (this._state === "PROVISION") { 130 | [heading, content] = this._renderProvision(); 131 | } else if (this._state === "LOGS") { 132 | [heading, content] = this._renderLogs(); 133 | } 134 | 135 | return html` 136 | 142 | ${heading ? html`
${heading}
` : ""} 143 | ${allowClosing 144 | ? html` 145 | 146 | ${closeIcon} 147 | 148 | ` 149 | : ""} 150 | ${content!} 151 |
152 | `; 153 | } 154 | 155 | _renderProgress(label: string | TemplateResult, progress?: number) { 156 | return html` 157 | 162 | `; 163 | } 164 | 165 | _renderError(label: string): [string, TemplateResult] { 166 | const heading = "Error"; 167 | const content = html` 168 | 173 |
174 | Close 175 |
176 | `; 177 | return [heading, content]; 178 | } 179 | 180 | _renderDashboard(): [string, TemplateResult, boolean] { 181 | const heading = this._manifest.name; 182 | let content: TemplateResult; 183 | let allowClosing = true; 184 | 185 | content = html` 186 |
187 | 188 | 189 |
Connected to ${this._info!.name}
190 |
191 | ${this._info!.firmware} ${this._info!.version} 192 | (${this._info!.chipFamily}) 193 |
194 |
195 | ${!this._isSameVersion 196 | ? html` 197 | { 200 | if (this._isSameFirmware) { 201 | this._startInstall(false); 202 | } else if (this._manifest.new_install_prompt_erase) { 203 | this._state = "ASK_ERASE"; 204 | } else { 205 | this._startInstall(true); 206 | } 207 | }} 208 | > 209 | ${listItemInstallIcon} 210 |
211 | ${!this._isSameFirmware 212 | ? `Install ${this._manifest.name}` 213 | : `Update ${this._manifest.name}`} 214 |
215 |
216 | ` 217 | : ""} 218 | ${this._client!.nextUrl === undefined 219 | ? "" 220 | : html` 221 | 226 | ${listItemVisitDevice} 227 |
Visit Device
228 |
229 | `} 230 | ${!this._manifest.home_assistant_domain || 231 | this._client!.state !== ImprovSerialCurrentState.PROVISIONED 232 | ? "" 233 | : html` 234 | 239 | ${listItemHomeAssistant} 240 |
Add to Home Assistant
241 |
242 | `} 243 | { 246 | this._state = "PROVISION"; 247 | if ( 248 | this._client!.state === ImprovSerialCurrentState.PROVISIONED 249 | ) { 250 | this._provisionForce = true; 251 | } 252 | }} 253 | > 254 | ${listItemWifi} 255 |
256 | ${this._client!.state === ImprovSerialCurrentState.READY 257 | ? "Connect to Wi-Fi" 258 | : "Change Wi-Fi"} 259 |
260 |
261 | { 264 | const client = this._client; 265 | if (client) { 266 | await this._closeClientWithoutEvents(client); 267 | await sleep(100); 268 | } 269 | // Also set `null` back to undefined. 270 | this._client = undefined; 271 | this._state = "LOGS"; 272 | }} 273 | > 274 | ${listItemConsole} 275 |
Logs & Console
276 |
277 | ${this._isSameFirmware && this._manifest.funding_url 278 | ? html` 279 | 284 | ${listItemFundDevelopment} 285 |
Fund Development
286 |
287 | ` 288 | : ""} 289 | ${this._isSameVersion 290 | ? html` 291 | this._startInstall(true)} 295 | > 296 | ${listItemEraseUserData} 297 |
Erase User Data
298 |
299 | ` 300 | : ""} 301 |
302 |
303 | `; 304 | 305 | return [heading, content, allowClosing]; 306 | } 307 | _renderDashboardNoImprov(): [string, TemplateResult, boolean] { 308 | const heading = this._manifest.name; 309 | let content: TemplateResult; 310 | let allowClosing = true; 311 | 312 | content = html` 313 |
314 | 315 | { 318 | if (this._manifest.new_install_prompt_erase) { 319 | this._state = "ASK_ERASE"; 320 | } else { 321 | // Default is to erase a device that does not support Improv Serial 322 | this._startInstall(true); 323 | } 324 | }} 325 | > 326 | ${listItemInstallIcon} 327 |
${`Install ${this._manifest.name}`}
328 |
329 | { 332 | // Also set `null` back to undefined. 333 | this._client = undefined; 334 | this._state = "LOGS"; 335 | }} 336 | > 337 | ${listItemConsole} 338 |
Logs & Console
339 |
340 |
341 |
342 | `; 343 | 344 | return [heading, content, allowClosing]; 345 | } 346 | 347 | _renderProvision(): [string | undefined, TemplateResult] { 348 | let heading: string | undefined = "Configure Wi-Fi"; 349 | let content: TemplateResult; 350 | 351 | if (this._busy) { 352 | return [ 353 | heading, 354 | this._renderProgress( 355 | this._ssids === undefined 356 | ? "Scanning for networks" 357 | : "Trying to connect", 358 | ), 359 | ]; 360 | } 361 | 362 | if ( 363 | !this._provisionForce && 364 | this._client!.state === ImprovSerialCurrentState.PROVISIONED 365 | ) { 366 | heading = undefined; 367 | const showSetupLinks = 368 | !this._wasProvisioned && 369 | (this._client!.nextUrl !== undefined || 370 | "home_assistant_domain" in this._manifest); 371 | content = html` 372 |
373 | 377 | ${showSetupLinks 378 | ? html` 379 | 380 | ${this._client!.nextUrl === undefined 381 | ? "" 382 | : html` 383 | { 388 | this._state = "DASHBOARD"; 389 | }} 390 | > 391 | ${listItemVisitDevice} 392 |
Visit Device
393 |
394 | `} 395 | ${!this._manifest.home_assistant_domain 396 | ? "" 397 | : html` 398 | { 403 | this._state = "DASHBOARD"; 404 | }} 405 | > 406 | ${listItemHomeAssistant} 407 |
Add to Home Assistant
408 |
409 | `} 410 | { 413 | this._state = "DASHBOARD"; 414 | }} 415 | > 416 |
417 |
Skip
418 |
419 |
420 | ` 421 | : ""} 422 |
423 | 424 | ${!showSetupLinks 425 | ? html` 426 |
427 | { 429 | this._state = "DASHBOARD"; 430 | }} 431 | > 432 | Continue 433 | 434 |
435 | ` 436 | : ""} 437 | `; 438 | } else { 439 | let error: string | undefined; 440 | 441 | switch (this._client!.error) { 442 | case ImprovSerialErrorState.UNABLE_TO_CONNECT: 443 | error = "Unable to connect"; 444 | break; 445 | 446 | case ImprovSerialErrorState.TIMEOUT: 447 | error = "Timeout"; 448 | break; 449 | 450 | case ImprovSerialErrorState.NO_ERROR: 451 | // Happens when list SSIDs not supported. 452 | case ImprovSerialErrorState.UNKNOWN_RPC_COMMAND: 453 | break; 454 | 455 | default: 456 | error = `Unknown error (${this._client!.error})`; 457 | } 458 | const selectedSsid = this._ssids?.find( 459 | (info) => info.name === this._selectedSsid, 460 | ); 461 | content = html` 462 | 463 | ${refreshIcon} 464 | 465 |
466 |
Connect your device to the network to start using it.
467 | ${error ? html`

${error}

` : ""} 468 | ${this._ssids !== null 469 | ? html` 470 | { 474 | const index = ev.target.selectedIndex; 475 | // The "Join Other" item is always the last item. 476 | this._selectedSsid = 477 | index === this._ssids!.length 478 | ? null 479 | : this._ssids![index].name; 480 | }} 481 | > 482 | ${this._ssids!.map( 483 | (info) => html` 484 | 488 | ${info.name} 489 | 490 | `, 491 | )} 492 | 493 | 494 | Join other… 495 | 496 | 497 | ` 498 | : ""} 499 | ${ 500 | // Show input box if command not supported or "Join Other" selected 501 | !selectedSsid 502 | ? html` 503 | 507 | ` 508 | : "" 509 | } 510 | ${!selectedSsid || selectedSsid.secured 511 | ? html` 512 | 517 | ` 518 | : ""} 519 |
520 |
521 | { 523 | this._state = "DASHBOARD"; 524 | }} 525 | > 526 | ${this._installState && this._installErase ? "Skip" : "Back"} 527 | 528 | Connect 529 |
530 | `; 531 | } 532 | return [heading, content]; 533 | } 534 | 535 | _renderAskErase(): [string | undefined, TemplateResult] { 536 | const heading = "Erase device"; 537 | const content = html` 538 |
539 |
540 | Do you want to erase the device before installing 541 | ${this._manifest.name}? All data on the device will be lost. 542 |
543 | 547 |
548 |
549 | { 551 | this._state = "DASHBOARD"; 552 | }} 553 | > 554 | Back 555 | 556 | { 558 | const checkbox = this.shadowRoot!.querySelector("ew-checkbox")!; 559 | this._startInstall(checkbox.checked); 560 | }} 561 | > 562 | Next 563 | 564 |
565 | `; 566 | 567 | return [heading, content]; 568 | } 569 | 570 | _renderInstall(): [string | undefined, TemplateResult, boolean] { 571 | let heading: string | undefined; 572 | let content: TemplateResult; 573 | const allowClosing = false; 574 | 575 | const isUpdate = !this._installErase && this._isSameFirmware; 576 | 577 | if (!this._installConfirmed && this._isSameVersion) { 578 | heading = "Erase User Data"; 579 | content = html` 580 |
581 | Do you want to reset your device and erase all user data from your 582 | device? 583 |
584 |
585 | 586 | Erase User Data 587 | 588 |
589 | `; 590 | } else if (!this._installConfirmed) { 591 | heading = "Confirm Installation"; 592 | const action = isUpdate ? "update to" : "install"; 593 | content = html` 594 |
595 | ${isUpdate 596 | ? html`Your device is running 597 | ${this._info!.firmware} ${this._info!.version}.

` 598 | : ""} 599 | Do you want to ${action} 600 | ${this._manifest.name} ${this._manifest.version}? 601 | ${this._installErase 602 | ? html`

All data on the device will be erased.` 603 | : ""} 604 |
605 |
606 | { 608 | this._state = "DASHBOARD"; 609 | }} 610 | > 611 | Back 612 | 613 | 614 | Install 615 | 616 |
617 | `; 618 | } else if ( 619 | !this._installState || 620 | this._installState.state === FlashStateType.INITIALIZING || 621 | this._installState.state === FlashStateType.PREPARING 622 | ) { 623 | heading = "Installing"; 624 | content = this._renderProgress("Preparing installation"); 625 | } else if (this._installState.state === FlashStateType.ERASING) { 626 | heading = "Installing"; 627 | content = this._renderProgress("Erasing"); 628 | } else if ( 629 | this._installState.state === FlashStateType.WRITING || 630 | // When we're finished, keep showing this screen with 100% written 631 | // until Improv is initialized / not detected. 632 | (this._installState.state === FlashStateType.FINISHED && 633 | this._client === undefined) 634 | ) { 635 | heading = "Installing"; 636 | let percentage: number | undefined; 637 | let undeterminateLabel: string | undefined; 638 | if (this._installState.state === FlashStateType.FINISHED) { 639 | // We're done writing and detecting improv, show spinner 640 | undeterminateLabel = "Wrapping up"; 641 | } else if (this._installState.details.percentage < 4) { 642 | // We're writing the firmware under 4%, show spinner or else we don't show any pixels 643 | undeterminateLabel = "Installing"; 644 | } else { 645 | // We're writing the firmware over 4%, show progress bar 646 | percentage = this._installState.details.percentage; 647 | } 648 | content = this._renderProgress( 649 | html` 650 | ${undeterminateLabel ? html`${undeterminateLabel}
` : ""} 651 |
652 | This will take 653 | ${this._installState.chipFamily === "ESP8266" 654 | ? "a minute" 655 | : "2 minutes"}.
656 | Keep this page visible to prevent slow down 657 | `, 658 | percentage, 659 | ); 660 | } else if (this._installState.state === FlashStateType.FINISHED) { 661 | heading = undefined; 662 | const supportsImprov = this._client !== null; 663 | content = html` 664 | 669 | 670 |
671 | { 673 | this._state = 674 | supportsImprov && this._installErase 675 | ? "PROVISION" 676 | : "DASHBOARD"; 677 | }} 678 | > 679 | Next 680 | 681 |
682 | `; 683 | } else if (this._installState.state === FlashStateType.ERROR) { 684 | heading = "Installation failed"; 685 | content = html` 686 | 691 |
692 | { 694 | this._initialize(); 695 | this._state = "DASHBOARD"; 696 | }} 697 | > 698 | Back 699 | 700 |
701 | `; 702 | } 703 | return [heading, content!, allowClosing]; 704 | } 705 | 706 | _renderLogs(): [string | undefined, TemplateResult] { 707 | let heading: string | undefined = `Logs`; 708 | let content: TemplateResult; 709 | 710 | content = html` 711 |
712 | 713 |
714 |
715 | { 717 | await this.shadowRoot!.querySelector("ewt-console")!.reset(); 718 | }} 719 | > 720 | Reset Device 721 | 722 | { 724 | textDownload( 725 | this.shadowRoot!.querySelector("ewt-console")!.logs(), 726 | `esp-web-tools-logs.txt`, 727 | ); 728 | 729 | this.shadowRoot!.querySelector("ewt-console")!.reset(); 730 | }} 731 | > 732 | Download Logs 733 | 734 | { 736 | await this.shadowRoot!.querySelector("ewt-console")!.disconnect(); 737 | this._state = "DASHBOARD"; 738 | this._initialize(); 739 | }} 740 | > 741 | Back 742 | 743 |
744 | `; 745 | 746 | return [heading, content!]; 747 | } 748 | 749 | public override willUpdate(changedProps: PropertyValues) { 750 | if (!changedProps.has("_state")) { 751 | return; 752 | } 753 | // Clear errors when changing between pages unless we change 754 | // to the error page. 755 | if (this._state !== "ERROR") { 756 | this._error = undefined; 757 | } 758 | // Scan for SSIDs on provision 759 | if (this._state === "PROVISION") { 760 | this._updateSsids(); 761 | } else { 762 | // Reset this value if we leave provisioning. 763 | this._provisionForce = false; 764 | } 765 | 766 | if (this._state === "INSTALL") { 767 | this._installConfirmed = false; 768 | this._installState = undefined; 769 | } 770 | } 771 | 772 | private async _updateSsids(tries = 0) { 773 | const oldSsids = this._ssids; 774 | this._ssids = undefined; 775 | this._busy = true; 776 | 777 | let ssids: Ssid[]; 778 | 779 | try { 780 | ssids = await this._client!.scan(); 781 | } catch (err) { 782 | // When we fail while loading, pick "Join other" 783 | if (this._ssids === undefined) { 784 | this._ssids = null; 785 | this._selectedSsid = null; 786 | } 787 | this._busy = false; 788 | return; 789 | } 790 | 791 | // We will retry a few times if we don't get any results 792 | if (ssids.length === 0 && tries < 3) { 793 | console.log("SCHEDULE RETRY", tries); 794 | setTimeout(() => this._updateSsids(tries + 1), 1000); 795 | return; 796 | } 797 | 798 | if (oldSsids) { 799 | // If we had a previous list, ensure the selection is still valid 800 | if ( 801 | this._selectedSsid && 802 | !ssids.find((s) => s.name === this._selectedSsid) 803 | ) { 804 | this._selectedSsid = ssids[0].name; 805 | } 806 | } else { 807 | this._selectedSsid = ssids.length ? ssids[0].name : null; 808 | } 809 | 810 | this._ssids = ssids; 811 | this._busy = false; 812 | } 813 | 814 | protected override firstUpdated(changedProps: PropertyValues) { 815 | super.firstUpdated(changedProps); 816 | this._bodyOverflow = document.body.style.overflow; 817 | document.body.style.overflow = "hidden"; 818 | this._initialize(); 819 | } 820 | 821 | protected override updated(changedProps: PropertyValues) { 822 | super.updated(changedProps); 823 | 824 | if (changedProps.has("_state")) { 825 | this.setAttribute("state", this._state); 826 | } 827 | 828 | if (this._state !== "PROVISION") { 829 | return; 830 | } 831 | 832 | if (changedProps.has("_selectedSsid") && this._selectedSsid === null) { 833 | // If we pick "Join other", select SSID input. 834 | this._focusFormElement("ew-filled-text-field[name=ssid]"); 835 | } else if (changedProps.has("_ssids")) { 836 | // Form is shown when SSIDs are loaded/marked not supported 837 | this._focusFormElement(); 838 | } 839 | } 840 | 841 | private _focusFormElement( 842 | selector = "ew-filled-text-field, ew-filled-select", 843 | ) { 844 | const formEl = this.shadowRoot!.querySelector( 845 | selector, 846 | ) as LitElement | null; 847 | if (formEl) { 848 | formEl.updateComplete.then(() => setTimeout(() => formEl.focus(), 100)); 849 | } 850 | } 851 | 852 | private async _initialize(justInstalled = false) { 853 | if (this.port.readable === null || this.port.writable === null) { 854 | this._state = "ERROR"; 855 | this._error = 856 | "Serial port is not readable/writable. Close any other application using it and try again."; 857 | return; 858 | } 859 | 860 | try { 861 | this._manifest = await downloadManifest(this.manifestPath); 862 | } catch (err: any) { 863 | this._state = "ERROR"; 864 | this._error = "Failed to download manifest"; 865 | return; 866 | } 867 | 868 | if (this._manifest.new_install_improv_wait_time === 0) { 869 | this._client = null; 870 | return; 871 | } 872 | 873 | const client = new ImprovSerial(this.port!, this.logger); 874 | client.addEventListener("state-changed", () => { 875 | this.requestUpdate(); 876 | }); 877 | client.addEventListener("error-changed", () => this.requestUpdate()); 878 | try { 879 | // If a device was just installed, give new firmware 10 seconds (overridable) to 880 | // format the rest of the flash and do other stuff. 881 | const timeout = !justInstalled 882 | ? 1000 883 | : this._manifest.new_install_improv_wait_time !== undefined 884 | ? this._manifest.new_install_improv_wait_time * 1000 885 | : 10000; 886 | this._info = await client.initialize(timeout); 887 | this._client = client; 888 | client.addEventListener("disconnect", this._handleDisconnect); 889 | } catch (err: any) { 890 | // Clear old value 891 | this._info = undefined; 892 | if (err instanceof PortNotReady) { 893 | this._state = "ERROR"; 894 | this._error = 895 | "Serial port is not ready. Close any other application using it and try again."; 896 | } else { 897 | this._client = null; // not supported 898 | this.logger.error("Improv initialization failed.", err); 899 | } 900 | } 901 | } 902 | 903 | private _startInstall(erase: boolean) { 904 | this._state = "INSTALL"; 905 | this._installErase = erase; 906 | this._installConfirmed = false; 907 | } 908 | 909 | private async _confirmInstall() { 910 | this._installConfirmed = true; 911 | this._installState = undefined; 912 | if (this._client) { 913 | await this._closeClientWithoutEvents(this._client); 914 | } 915 | this._client = undefined; 916 | 917 | // Close port. ESPLoader likes opening it. 918 | await this.port.close(); 919 | flash( 920 | (state) => { 921 | this._installState = state; 922 | 923 | if (state.state === FlashStateType.FINISHED) { 924 | sleep(100) 925 | // Flashing closes the port 926 | .then(() => this.port.open({ baudRate: 115200, bufferSize: 8192 })) 927 | .then(() => this._initialize(true)) 928 | .then(() => this.requestUpdate()); 929 | } else if (state.state === FlashStateType.ERROR) { 930 | sleep(100) 931 | // Flashing closes the port 932 | .then(() => this.port.open({ baudRate: 115200, bufferSize: 8192 })); 933 | } 934 | }, 935 | this.port, 936 | this.manifestPath, 937 | this._manifest, 938 | this._installErase, 939 | ); 940 | } 941 | 942 | private async _doProvision() { 943 | this._busy = true; 944 | this._wasProvisioned = 945 | this._client!.state === ImprovSerialCurrentState.PROVISIONED; 946 | const ssid = 947 | this._selectedSsid === null 948 | ? ( 949 | this.shadowRoot!.querySelector( 950 | "ew-filled-text-field[name=ssid]", 951 | ) as EwFilledTextField 952 | ).value 953 | : this._selectedSsid; 954 | const password = 955 | ( 956 | this.shadowRoot!.querySelector( 957 | "ew-filled-text-field[name=password]", 958 | ) as EwFilledTextField | null 959 | )?.value || ""; 960 | try { 961 | await this._client!.provision(ssid, password, 30000); 962 | } catch (err: any) { 963 | return; 964 | } finally { 965 | this._busy = false; 966 | this._provisionForce = false; 967 | } 968 | } 969 | 970 | private _handleDisconnect = () => { 971 | this._state = "ERROR"; 972 | this._error = "Disconnected"; 973 | }; 974 | 975 | private _closeDialog() { 976 | this.shadowRoot!.querySelector("ew-dialog")!.close(); 977 | } 978 | 979 | private async _handleClose() { 980 | if (this._client) { 981 | await this._closeClientWithoutEvents(this._client); 982 | } 983 | fireEvent(this, "closed" as any); 984 | document.body.style.overflow = this._bodyOverflow!; 985 | this.parentNode!.removeChild(this); 986 | } 987 | 988 | /** 989 | * Return if the device runs same firmware as manifest. 990 | */ 991 | private get _isSameFirmware() { 992 | return !this._info 993 | ? false 994 | : this.overrides?.checkSameFirmware 995 | ? this.overrides.checkSameFirmware(this._manifest, this._info) 996 | : this._info.firmware === this._manifest.name; 997 | } 998 | 999 | /** 1000 | * Return if the device runs same firmware and version as manifest. 1001 | */ 1002 | private get _isSameVersion() { 1003 | return ( 1004 | this._isSameFirmware && this._info!.version === this._manifest.version 1005 | ); 1006 | } 1007 | 1008 | private async _closeClientWithoutEvents(client: ImprovSerial) { 1009 | client.removeEventListener("disconnect", this._handleDisconnect); 1010 | await client.close(); 1011 | } 1012 | 1013 | private _preventDefault(ev: Event) { 1014 | ev.preventDefault(); 1015 | } 1016 | 1017 | static styles = [ 1018 | dialogStyles, 1019 | css` 1020 | :host { 1021 | --mdc-dialog-max-width: 390px; 1022 | } 1023 | div[slot="headline"] { 1024 | padding-right: 48px; 1025 | } 1026 | ew-icon-button[slot="headline"] { 1027 | position: absolute; 1028 | right: 4px; 1029 | top: 8px; 1030 | } 1031 | ew-icon-button[slot="headline"] svg { 1032 | padding: 8px; 1033 | color: var(--text-color); 1034 | } 1035 | .dialog-nav svg { 1036 | color: var(--text-color); 1037 | } 1038 | .table-row { 1039 | display: flex; 1040 | } 1041 | .table-row.last { 1042 | margin-bottom: 16px; 1043 | } 1044 | .table-row svg { 1045 | width: 20px; 1046 | margin-right: 8px; 1047 | } 1048 | ew-filled-text-field, 1049 | ew-filled-select { 1050 | display: block; 1051 | margin-top: 16px; 1052 | } 1053 | label.formfield { 1054 | display: inline-flex; 1055 | align-items: center; 1056 | padding-right: 8px; 1057 | } 1058 | ew-list { 1059 | margin: 0 -24px; 1060 | padding: 0; 1061 | } 1062 | ew-list-item svg { 1063 | height: 24px; 1064 | } 1065 | ewt-page-message + ew-list { 1066 | padding-top: 16px; 1067 | } 1068 | .fake-icon { 1069 | width: 24px; 1070 | } 1071 | .error { 1072 | color: var(--danger-color); 1073 | } 1074 | .danger { 1075 | --mdc-theme-primary: var(--danger-color); 1076 | --mdc-theme-secondary: var(--danger-color); 1077 | --md-sys-color-primary: var(--danger-color); 1078 | --md-sys-color-on-surface: var(--danger-color); 1079 | } 1080 | button.link { 1081 | background: none; 1082 | color: inherit; 1083 | border: none; 1084 | padding: 0; 1085 | font: inherit; 1086 | text-align: left; 1087 | text-decoration: underline; 1088 | cursor: pointer; 1089 | } 1090 | :host([state="LOGS"]) ew-dialog { 1091 | max-width: 90vw; 1092 | max-height: 90vh; 1093 | } 1094 | ewt-console { 1095 | width: calc(80vw - 48px); 1096 | height: calc(90vh - 168px); 1097 | } 1098 | `, 1099 | ]; 1100 | } 1101 | 1102 | customElements.define("ewt-install-dialog", EwtInstallDialog); 1103 | 1104 | declare global { 1105 | interface HTMLElementTagNameMap { 1106 | "ewt-install-dialog": EwtInstallDialog; 1107 | } 1108 | } 1109 | --------------------------------------------------------------------------------