Repository: esphome/esp-web-tools Branch: main Commit: fad174ff6ec8 Files: 52 Total size: 144.4 KB Directory structure: gitextract_kivu888r/ ├── .devcontainer/ │ ├── Dockerfile │ └── devcontainer.json ├── .github/ │ ├── dependabot.yml │ ├── release-drafter.yml │ └── workflows/ │ ├── ci.yml │ ├── npmpublish.yml │ └── release-drafter.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── LICENSE ├── README.md ├── index.html ├── package.json ├── rollup.config.mjs ├── script/ │ ├── build │ ├── develop │ └── stubgen.py ├── src/ │ ├── components/ │ │ ├── ew-checkbox.ts │ │ ├── ew-circular-progress.ts │ │ ├── ew-dialog.ts │ │ ├── ew-divider.ts │ │ ├── ew-filled-select.ts │ │ ├── ew-filled-text-field.ts │ │ ├── ew-icon-button.ts │ │ ├── ew-list-item.ts │ │ ├── ew-list.ts │ │ ├── ew-select-option.ts │ │ ├── ew-text-button.ts │ │ ├── ewt-console.ts │ │ ├── ewt-dialog.ts │ │ └── svg.ts │ ├── connect.ts │ ├── const.ts │ ├── flash.ts │ ├── install-button.ts │ ├── install-dialog.ts │ ├── no-port-picked/ │ │ ├── index.ts │ │ └── no-port-picked-dialog.ts │ ├── pages/ │ │ ├── ewt-page-message.ts │ │ └── ewt-page-progress.ts │ ├── styles.ts │ ├── util/ │ │ ├── console-color.ts │ │ ├── file-download.ts │ │ ├── fire-event.ts │ │ ├── get-operating-system.ts │ │ ├── line-break-transformer.ts │ │ ├── manifest.ts │ │ ├── sleep.ts │ │ └── timestamp-transformer.ts │ └── version.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/typescript-node/.devcontainer/base.Dockerfile # [Choice] Node.js version: 16, 14, 12 ARG VARIANT="16-buster" FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} # [Optional] Uncomment this section to install additional OS packages. # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ # && apt-get -y install --no-install-recommends # [Optional] Uncomment if you want to install an additional version of node using nvm # ARG EXTRA_NODE_VERSION=10 # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" # [Optional] Uncomment if you want to install more global node packages # RUN su node -c "npm install -g " ================================================ FILE: .devcontainer/devcontainer.json ================================================ // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/typescript-node { "name": "Node.js & TypeScript", "build": { "dockerfile": "Dockerfile", // Update 'VARIANT' to pick a Node version: 12, 14, 16, 18, 20 "args": { "VARIANT": "20" } }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "bierner.lit-html", "runem.lit-plugin" ], // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [5000], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "npm install", // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "node", "settings": { "files.eol": "\n", "editor.tabSize": 2, "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "files.trimTrailingWhitespace": true } } ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: weekly - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" ================================================ FILE: .github/release-drafter.yml ================================================ categories: - title: "Breaking Changes" labels: - "breaking change" - title: "Dependencies" collapse-after: 1 labels: - "dependencies" template: | ## What's Changed $CHANGES ================================================ FILE: .github/workflows/ci.yml ================================================ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions name: CI on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install jq tool run: | sudo apt-get update sudo apt-get install jq - name: Use Node.js uses: actions/setup-node@v6 with: node-version: 16 - run: npm ci - run: script/build - run: npm exec -- prettier --check src ================================================ FILE: .github/workflows/npmpublish.yml ================================================ name: Node.js Package on: release: types: [published] jobs: publish-npm: runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@v6 - name: Install jq tool run: | sudo apt-get update sudo apt-get install jq - uses: actions/setup-node@v6 with: node-version: 20 registry-url: https://registry.npmjs.org/ - name: Update npm to latest run: npm install -g npm@latest - run: npm ci - name: Set version from release tag run: | VERSION="${{ github.event.release.tag_name }}" VERSION="${VERSION#v}" jq --arg v "$VERSION" '.version = $v' package.json > package.json.tmp && mv package.json.tmp package.json - run: npm publish --provenance ================================================ FILE: .github/workflows/release-drafter.yml ================================================ name: Release Drafter on: push: branches: - main jobs: update_release_draft: runs-on: ubuntu-latest steps: - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ dist node_modules ================================================ FILE: .npmignore ================================================ index.html firmware_build demo ================================================ FILE: .prettierignore ================================================ src/vendor ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # ESP Web Tools 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/) ```html ``` Example manifest: ```json { "name": "ESPHome", "version": "2021.10.3", "home_assistant_domain": "esphome", "funding_url": "https://esphome.io/guides/supporters.html", "builds": [ { "chipFamily": "ESP32", "parts": [ { "path": "bootloader_dout_40m.bin", "offset": 4096 }, { "path": "partitions.bin", "offset": 32768 }, { "path": "boot_app0.bin", "offset": 57344 }, { "path": "esp32.bin", "offset": 65536 } ] }, { "chipFamily": "ESP32-C3", "parts": [ { "path": "bootloader_dout_40m.bin", "offset": 0 }, { "path": "partitions.bin", "offset": 32768 }, { "path": "boot_app0.bin", "offset": 57344 }, { "path": "esp32-c3.bin", "offset": 65536 } ] }, { "chipFamily": "ESP32-S2", "parts": [ { "path": "bootloader_dout_40m.bin", "offset": 4096 }, { "path": "partitions.bin", "offset": 32768 }, { "path": "boot_app0.bin", "offset": 57344 }, { "path": "esp32-s2.bin", "offset": 65536 } ] }, { "chipFamily": "ESP32-S3", "parts": [ { "path": "bootloader_dout_40m.bin", "offset": 4096 }, { "path": "partitions.bin", "offset": 32768 }, { "path": "boot_app0.bin", "offset": 57344 }, { "path": "esp32-s3.bin", "offset": 65536 } ] }, { "chipFamily": "ESP8266", "parts": [ { "path": "esp8266.bin", "offset": 0 } ] } ] } ``` ## Development Run `script/develop`. This starts a server. Open it on http://localhost:5001. [![ESPHome - A project from the Open Home Foundation](https://www.openhomefoundation.org/badges/esphome.png)](https://www.openhomefoundation.org/) ================================================ FILE: index.html ================================================ ESP Web Tools

ESP Web Tools

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

  • Install & update firmware
  • Connect device to the Wi-Fi network
  • Visit the device's hosted web interface
  • Access logs and send terminal commands
  • Add devices to Home Assistant

Try a live demo

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

The demo is not available because your browser does not support Web Serial. Open this page in Google Chrome or Microsoft Edge instead.

Products using ESP Web Tools

How it works

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

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

Configuring Wi-Fi

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

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

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

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. Screenshot showing the ESP Web Tools interface

Viewing logs & sending commands

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

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

Adding ESP Web Tools to your website

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

Click here to see a full example.

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

<script
  type="module"
  src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js?module"
></script>

(If you prefer to locally host the JavaScript, download it here)

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

<esp-web-install-button
  manifest="https://firmware.esphome.io/esp-web-tools/manifest.json"
></esp-web-install-button>

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

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

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

Preparing your firmware

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

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

Create a single binary using esptool with the following command:

esptool --chip esp32 merge_bin \
  -o merged-firmware.bin \
  --flash_mode dio \
  --flash_freq 40m \
  --flash_size 4MB \
  0x1000 bootloader.bin \
  0x8000 partitions.bin \
  0xe000 boot.bin \
  0x10000 your_app.bin

If your memory type is opi_opi or opi_qspi, set your flash mode to be dout. Else, if your flash mode is qio or qout, override your flash mode to be dio.

Creating your manifest

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

{
  "name": "ESPHome",
  "version": "2021.11.0",
  "home_assistant_domain": "esphome",
  "funding_url": "https://esphome.io/guides/supporters.html",
  "new_install_prompt_erase": false,
  "builds": [
    {
      "chipFamily": "ESP32",
      "parts": [
        { "path": "merged-firmware.bin", "offset": 0 },
      ]
    },
    {
      "chipFamily": "ESP8266",
      "parts": [
        { "path": "esp8266.bin", "offset": 0 }
      ]
    }
  ]
}

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

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

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

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

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

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

const button = document.querySelector('esp-web-install-button');
button.overrides = {
  checkSameFirmware(manifest, improvInfo) {
    const manifestFirmware = manifest.name.toLowerCase();
    const deviceFirmware = improvInfo.firmware.toLowerCase();
    return manifestFirmware.includes(deviceFirmware);
  }
};

Generating a manifest dynamically & version management

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

const manifest = {
    "name": name,
    "version": version,
    "funding_url": funding_url,
    "new_install_prompt_erase": true,
    "builds": [
        {
            "chipFamily": "ESP32",
            "improv": false,
            "parts": [
                { "path": dependenciesDir+"bootloader.bin", "offset": 4096 },
                { "path": dependenciesDir+"partitions.bin", "offset": 32768 },
                { "path": dependenciesDir+"boot_app0.bin", "offset": 57344 },
                { "path": firmwareFile, "offset": 65536 }
            ]
        }
    ]
}

const json = JSON.stringify(manifest);
const blob = new Blob([json], {type: "application/json"});
document.querySelector("esp-web-install-button").manifest = URL.createObjectURL(blob);
      

Customizing the look and feel

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

  • --esp-tools-button-color
  • --esp-tools-button-text-color
  • --esp-tools-button-border-radius

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

install-supported Added if installing firmware is supported
install-unsupported Added if installing firmware is not supported

Replace the button and message with a custom one

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

<esp-web-install-button
  manifest="https://firmware.esphome.io/esp-web-tools/manifest.json"
>
  <button slot="activate">Custom install button</button>
  <span slot="unsupported">Ah snap, your browser doesn't work!</span>
  <span slot="not-allowed">Ah snap, you are not allowed to use this on HTTP!</span>
</esp-web-install-button>
    

Why we created ESP Web Tools

================================================ FILE: package.json ================================================ { "name": "esp-web-tools", "version": "0.0.0", "description": "Web tools for ESP devices", "main": "dist/install-button.js", "repository": { "type": "git", "url": "https://github.com/esphome/esp-web-tools" }, "author": "ESPHome maintainers", "license": "Apache-2.0", "scripts": { "prepublishOnly": "script/build" }, "devDependencies": { "@babel/preset-env": "^7.26.0", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.2", "@types/w3c-web-serial": "^1.0.7", "prettier": "^3.4.2", "rollup": "^4.29.1", "serve": "^14.2.4", "typescript": "^5.7.2" }, "dependencies": { "@material/web": "^2.2.0", "esptool-js": "^0.5.7", "improv-wifi-serial-sdk": "^2.5.0", "lit": "^3.2.1", "pako": "^2.1.0", "tslib": "^2.8.1" } } ================================================ FILE: rollup.config.mjs ================================================ import nodeResolve from "@rollup/plugin-node-resolve"; import json from "@rollup/plugin-json"; import terser from "@rollup/plugin-terser"; import babel from "@rollup/plugin-babel"; import commonjs from "@rollup/plugin-commonjs"; const config = { input: "dist/install-button.js", output: { dir: "dist/web", format: "module", }, external: ["https://www.improv-wifi.com/sdk-js/launch-button.js"], preserveEntrySignatures: false, plugins: [ commonjs(), nodeResolve({ browser: true, preferBuiltins: false, }), babel({ babelHelpers: "bundled", presets: [ [ "@babel/preset-env", { targets: { // We use unpkg as CDN and it doesn't bundle modern syntax chrome: "84", }, }, ], ], }), json(), ], }; if (process.env.NODE_ENV === "production") { config.plugins.push( terser({ ecma: 2019, toplevel: true, format: { comments: false, }, }) ); } export default config; ================================================ FILE: script/build ================================================ # Stop on errors set -e cd "$(dirname "$0")/.." echo 'export const version =' `jq .version package.json`";" > src/version.ts rm -rf dist NODE_ENV=production npm exec -- tsc NODE_ENV=production npm exec -- rollup -c ================================================ FILE: script/develop ================================================ # Stop on errors set -e if [ -z "$PORT" ]; then PORT=5001 fi cd "$(dirname "$0")/.." rm -rf dist # Quit all background tasks when script exits trap "kill 0" EXIT # Run tsc once as rollup expects those files npm exec -- tsc || true npm exec -- serve -p "$PORT" & npm exec -- tsc --watch & npm exec -- rollup -c --watch ================================================ FILE: script/stubgen.py ================================================ import base64 import zlib import json stubs = { "esp8266": b""" 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\ """, "esp32": b""" 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\ """, "esp32s2": b""" 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\ """, } for key, stub in stubs.items(): code = eval(zlib.decompress(base64.b64decode(stub))) print("Processing " + key) print("Text size:", str(len(code["text"])) + " bytes") print("Data size:", str(len(code["data"])) + " bytes") print(code["text"]) print(base64.b64encode(code["text"])) code["text"] = base64.b64encode(code["text"]).decode("utf-8") code["data"] = base64.b64encode(code["data"]).decode("utf-8") jsondata = json.dumps(code, indent=2) f = open(f"src/vendor/esptool/stubs/{key}.json", "w+") f.write(jsondata) f.close() ================================================ FILE: src/components/ew-checkbox.ts ================================================ import { Checkbox } from "@material/web/checkbox/internal/checkbox.js"; import { styles } from "@material/web/checkbox/internal/checkbox-styles.js"; declare global { interface HTMLElementTagNameMap { "ew-checkbox": EwCheckbox; } } export class EwCheckbox extends Checkbox { static override styles = [styles]; } customElements.define("ew-checkbox", EwCheckbox); ================================================ FILE: src/components/ew-circular-progress.ts ================================================ import { CircularProgress } from "@material/web/progress/internal/circular-progress.js"; import { styles } from "@material/web/progress/internal/circular-progress-styles.js"; declare global { interface HTMLElementTagNameMap { "ew-circular-progress": EwCircularProgress; } } export class EwCircularProgress extends CircularProgress { static override styles = [styles]; } customElements.define("ew-circular-progress", EwCircularProgress); ================================================ FILE: src/components/ew-dialog.ts ================================================ import { Dialog } from "@material/web/dialog/internal/dialog.js"; import { styles } from "@material/web/dialog/internal/dialog-styles.js"; declare global { interface HTMLElementTagNameMap { "ew-dialog": EwDialog; } } export class EwDialog extends Dialog { static override styles = [styles]; } customElements.define("ew-dialog", EwDialog); ================================================ FILE: src/components/ew-divider.ts ================================================ import { Divider } from "@material/web/divider/internal/divider.js"; import { styles } from "@material/web/divider/internal/divider-styles.js"; declare global { interface HTMLElementTagNameMap { "ew-divider": EwDivider; } } export class EwDivider extends Divider { static override styles = [styles]; } customElements.define("ew-divider", EwDivider); ================================================ FILE: src/components/ew-filled-select.ts ================================================ import { FilledSelect } from "@material/web/select/internal/filled-select.js"; import { styles } from "@material/web/select/internal/filled-select-styles.js"; import { styles as sharedStyles } from "@material/web/select/internal/shared-styles.js"; declare global { interface HTMLElementTagNameMap { "ew-filled-select": EwFilledSelect; } } export class EwFilledSelect extends FilledSelect { static override styles = [sharedStyles, styles]; } customElements.define("ew-filled-select", EwFilledSelect); ================================================ FILE: src/components/ew-filled-text-field.ts ================================================ import { styles as filledStyles } from "@material/web/textfield/internal/filled-styles.js"; import { FilledTextField } from "@material/web/textfield/internal/filled-text-field.js"; import { styles as sharedStyles } from "@material/web/textfield/internal/shared-styles.js"; import { literal } from "lit/static-html.js"; declare global { interface HTMLElementTagNameMap { "ew-filled-text-field": EwFilledTextField; } } export class EwFilledTextField extends FilledTextField { static override styles = [sharedStyles, filledStyles]; protected override readonly fieldTag = literal`md-filled-field`; } customElements.define("ew-filled-text-field", EwFilledTextField); ================================================ FILE: src/components/ew-icon-button.ts ================================================ import { IconButton } from "@material/web/iconbutton/internal/icon-button.js"; import { styles as sharedStyles } from "@material/web/iconbutton/internal/shared-styles.js"; import { styles } from "@material/web/iconbutton/internal/standard-styles.js"; declare global { interface HTMLElementTagNameMap { "ew-icon-button": EwIconButton; } } export class EwIconButton extends IconButton { static override styles = [sharedStyles, styles]; } customElements.define("ew-icon-button", EwIconButton); ================================================ FILE: src/components/ew-list-item.ts ================================================ import { ListItemEl as ListItem } from "@material/web/list/internal/listitem/list-item.js"; import { styles } from "@material/web/list/internal/listitem/list-item-styles.js"; declare global { interface HTMLElementTagNameMap { "ew-list-item": EwListItem; } } export class EwListItem extends ListItem { static override styles = [styles]; } customElements.define("ew-list-item", EwListItem); ================================================ FILE: src/components/ew-list.ts ================================================ import { List } from "@material/web/list/internal/list.js"; import { styles } from "@material/web/list/internal/list-styles.js"; declare global { interface HTMLElementTagNameMap { "ew-list": EwList; } } export class EwList extends List { static override styles = [styles]; } customElements.define("ew-list", EwList); ================================================ FILE: src/components/ew-select-option.ts ================================================ import { styles } from "@material/web/menu/internal/menuitem/menu-item-styles.js"; import { SelectOptionEl } from "@material/web/select/internal/selectoption/select-option.js"; declare global { interface HTMLElementTagNameMap { "ew-select-option": EwSelectOption; } } export class EwSelectOption extends SelectOptionEl { static override styles = [styles]; } customElements.define("ew-select-option", EwSelectOption); ================================================ FILE: src/components/ew-text-button.ts ================================================ import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.js"; import { TextButton } from "@material/web/button/internal/text-button.js"; import { styles as textStyles } from "@material/web/button/internal/text-styles.js"; declare global { interface HTMLElementTagNameMap { "ew-text-button": EwTextButton; } } export class EwTextButton extends TextButton { static override styles = [sharedStyles, textStyles]; } customElements.define("ew-text-button", EwTextButton); ================================================ FILE: src/components/ewt-console.ts ================================================ import { ColoredConsole, coloredConsoleStyles } from "../util/console-color"; import { sleep } from "../util/sleep"; import { LineBreakTransformer } from "../util/line-break-transformer"; import { TimestampTransformer } from "../util/timestamp-transformer"; import { Logger } from "../const"; import { Transport, HardReset } from "esptool-js"; export class EwtConsole extends HTMLElement { public port!: SerialPort; public logger!: Logger; public allowInput = true; private _console?: ColoredConsole; private _cancelConnection?: () => Promise; public logs(): string { return this._console?.logs() || ""; } public connectedCallback() { if (this._console) { return; } const shadowRoot = this.attachShadow({ mode: "open" }); shadowRoot.innerHTML = `
${ this.allowInput ? `
>
` : "" } `; this._console = new ColoredConsole(this.shadowRoot!.querySelector("div")!); if (this.allowInput) { const input = this.shadowRoot!.querySelector("input")!; this.addEventListener("click", () => { // Only focus input if user didn't select some text if (getSelection()?.toString() === "") { input.focus(); } }); input.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); ev.stopPropagation(); this._sendCommand(); } }); } const abortController = new AbortController(); const connection = this._connect(abortController.signal); this._cancelConnection = () => { abortController.abort(); return connection; }; } private async _connect(abortSignal: AbortSignal) { this.logger.debug("Starting console read loop"); try { await this.port .readable!.pipeThrough( new TextDecoderStream() as ReadableWritablePair, { signal: abortSignal, }, ) .pipeThrough(new TransformStream(new LineBreakTransformer())) .pipeThrough(new TransformStream(new TimestampTransformer())) .pipeTo( new WritableStream({ write: (chunk) => { this._console!.addLine(chunk.replace("\r", "")); }, }), ); if (!abortSignal.aborted) { this._console!.addLine(""); this._console!.addLine(""); this._console!.addLine("Terminal disconnected"); } } catch (e) { this._console!.addLine(""); this._console!.addLine(""); this._console!.addLine(`Terminal disconnected: ${e}`); } finally { await sleep(100); this.logger.debug("Finished console read loop"); } } private async _sendCommand() { const input = this.shadowRoot!.querySelector("input")!; const command = input.value; const encoder = new TextEncoder(); const writer = this.port.writable!.getWriter(); await writer.write(encoder.encode(command + "\r\n")); this._console!.addLine(`> ${command}\r\n`); input.value = ""; input.focus(); try { writer.releaseLock(); } catch (err) { console.error("Ignoring release lock error", err); } } public async disconnect() { if (this._cancelConnection) { await this._cancelConnection(); this._cancelConnection = undefined; } } public async reset() { this.logger.debug("Triggering reset"); const transport = new Transport(this.port); // First assert RTS to enter reset state await transport.setRTS(true); await sleep(100); // Use HardReset to release (same as esploader.after()) const resetStrategy = new HardReset(transport); await resetStrategy.reset(); } } customElements.define("ewt-console", EwtConsole); declare global { interface HTMLElementTagNameMap { "ewt-console": EwtConsole; } } ================================================ FILE: src/components/ewt-dialog.ts ================================================ import { DialogBase } from "@material/mwc-dialog/mwc-dialog-base"; import { styles } from "@material/mwc-dialog/mwc-dialog.css"; import { css } from "lit"; declare global { interface HTMLElementTagNameMap { "ewt-dialog": EwtDialog; } } export class EwtDialog extends DialogBase { static override styles = [ styles, css` .mdc-dialog__title { padding-right: 52px; } `, ]; } customElements.define("ewt-dialog", EwtDialog); ================================================ FILE: src/components/svg.ts ================================================ import { svg } from "lit"; export const closeIcon = svg` `; export const refreshIcon = svg` `; export const listItemInstallIcon = svg` `; export const listItemWifi = svg` `; export const listItemConsole = svg` `; export const listItemVisitDevice = svg` `; export const listItemHomeAssistant = svg` `; export const listItemEraseUserData = svg` `; export const listItemFundDevelopment = svg` `; ================================================ FILE: src/connect.ts ================================================ import type { InstallButton } from "./install-button.js"; export const connect = async (button: InstallButton) => { import("./install-dialog.js"); let port: SerialPort | undefined; try { port = await navigator.serial.requestPort(); } catch (err: any) { if ((err as DOMException).name === "NotFoundError") { import("./no-port-picked/index").then((mod) => mod.openNoPortPickedDialog(() => connect(button)), ); return; } alert(`Error: ${err.message}`); return; } if (!port) { return; } try { await port.open({ baudRate: 115200, bufferSize: 8192 }); } catch (err: any) { alert(err.message); return; } const el = document.createElement("ewt-install-dialog"); el.port = port; el.manifestPath = button.manifest || button.getAttribute("manifest")!; el.overrides = button.overrides; el.addEventListener( "closed", () => { port!.close(); }, { once: true }, ); document.body.appendChild(el); }; ================================================ FILE: src/const.ts ================================================ export interface Logger { log(msg: string, ...args: any[]): void; error(msg: string, ...args: any[]): void; debug(msg: string, ...args: any[]): void; } export interface Build { chipFamily: | "ESP32" | "ESP32-C2" | "ESP32-C3" | "ESP32-C5" | "ESP32-C6" | "ESP32-C61" | "ESP32-H2" | "ESP32-P4" | "ESP32-S2" | "ESP32-S3" | "ESP8266"; parts: { path: string; offset: number; }[]; } export interface Manifest { name: string; version: string; home_assistant_domain?: string; funding_url?: string; /** @deprecated use `new_install_prompt_erase` instead */ new_install_skip_erase?: boolean; new_install_prompt_erase?: boolean; /* Time to wait to detect Improv Wi-Fi. Set to 0 to disable. */ new_install_improv_wait_time?: number; builds: Build[]; } export interface BaseFlashState { state: FlashStateType; message: string; manifest?: Manifest; build?: Build; chipFamily?: Build["chipFamily"] | "Unknown Chip"; } export interface InitializingState extends BaseFlashState { state: FlashStateType.INITIALIZING; details: { done: boolean }; } export interface PreparingState extends BaseFlashState { state: FlashStateType.PREPARING; details: { done: boolean }; } export interface ErasingState extends BaseFlashState { state: FlashStateType.ERASING; details: { done: boolean }; } export interface WritingState extends BaseFlashState { state: FlashStateType.WRITING; details: { bytesTotal: number; bytesWritten: number; percentage: number }; } export interface FinishedState extends BaseFlashState { state: FlashStateType.FINISHED; } export interface ErrorState extends BaseFlashState { state: FlashStateType.ERROR; details: { error: FlashError; details: string | Error }; } export type FlashState = | InitializingState | PreparingState | ErasingState | WritingState | FinishedState | ErrorState; export const enum FlashStateType { INITIALIZING = "initializing", PREPARING = "preparing", ERASING = "erasing", WRITING = "writing", FINISHED = "finished", ERROR = "error", } export const enum FlashError { FAILED_INITIALIZING = "failed_initialize", FAILED_MANIFEST_FETCH = "fetch_manifest_failed", NOT_SUPPORTED = "not_supported", FAILED_FIRMWARE_DOWNLOAD = "failed_firmware_download", WRITE_FAILED = "write_failed", } declare global { interface HTMLElementEventMap { "state-changed": CustomEvent; } } ================================================ FILE: src/flash.ts ================================================ import { Transport, ESPLoader } from "esptool-js"; import { Build, FlashError, FlashState, Manifest, FlashStateType, } from "./const"; import { sleep } from "./util/sleep"; /** * Perform a hard reset using esploader's chip-specific reset logic. * First asserts RTS to enter reset, then calls esploader.after() which * uses the chip-specific reset procedure to release. */ const hardResetDevice = async (transport: Transport, esploader: ESPLoader) => { await transport.setRTS(true); await sleep(100); await esploader.after(); }; export const flash = async ( onEvent: (state: FlashState) => void, port: SerialPort, manifestPath: string, manifest: Manifest, eraseFirst: boolean, ) => { let build: Build | undefined; let chipFamily: Build["chipFamily"]; const fireStateEvent = (stateUpdate: FlashState) => onEvent({ ...stateUpdate, manifest, build, chipFamily, }); const transport = new Transport(port); const esploader = new ESPLoader({ transport, baudrate: 115200, romBaudrate: 115200, enableTracing: false, }); // For debugging (window as any).esploader = esploader; fireStateEvent({ state: FlashStateType.INITIALIZING, message: "Initializing...", details: { done: false }, }); try { await esploader.main(); await esploader.flashId(); } catch (err: any) { console.error(err); fireStateEvent({ state: FlashStateType.ERROR, message: "Failed to initialize. Try resetting your device or holding the BOOT button while clicking INSTALL.", details: { error: FlashError.FAILED_INITIALIZING, details: err }, }); await hardResetDevice(transport, esploader); await transport.disconnect(); return; } chipFamily = esploader.chip.CHIP_NAME as any; fireStateEvent({ state: FlashStateType.INITIALIZING, message: `Initialized. Found ${chipFamily}`, details: { done: true }, }); build = manifest.builds.find((b) => b.chipFamily === chipFamily); if (!build) { fireStateEvent({ state: FlashStateType.ERROR, message: `Your ${chipFamily} board is not supported.`, details: { error: FlashError.NOT_SUPPORTED, details: chipFamily }, }); await hardResetDevice(transport, esploader); await transport.disconnect(); return; } fireStateEvent({ state: FlashStateType.PREPARING, message: "Preparing installation...", details: { done: false }, }); const manifestURL = new URL(manifestPath, location.toString()).toString(); const filePromises = build.parts.map(async (part) => { const url = new URL(part.path, manifestURL).toString(); const resp = await fetch(url); if (!resp.ok) { throw new Error( `Downloading firmware ${part.path} failed: ${resp.status}`, ); } const reader = new FileReader(); const blob = await resp.blob(); return new Promise((resolve) => { reader.addEventListener("load", () => resolve(reader.result as string)); reader.readAsBinaryString(blob); }); }); const fileArray: Array<{ data: string; address: number }> = []; let totalSize = 0; for (let part = 0; part < filePromises.length; part++) { try { const data = await filePromises[part]; fileArray.push({ data, address: build.parts[part].offset }); totalSize += data.length; } catch (err: any) { fireStateEvent({ state: FlashStateType.ERROR, message: err.message, details: { error: FlashError.FAILED_FIRMWARE_DOWNLOAD, details: err.message, }, }); await hardResetDevice(transport, esploader); await transport.disconnect(); return; } } fireStateEvent({ state: FlashStateType.PREPARING, message: "Installation prepared", details: { done: true }, }); if (eraseFirst) { fireStateEvent({ state: FlashStateType.ERASING, message: "Erasing device...", details: { done: false }, }); await esploader.eraseFlash(); fireStateEvent({ state: FlashStateType.ERASING, message: "Device erased", details: { done: true }, }); } fireStateEvent({ state: FlashStateType.WRITING, message: `Writing progress: 0%`, details: { bytesTotal: totalSize, bytesWritten: 0, percentage: 0, }, }); let totalWritten = 0; try { await esploader.writeFlash({ fileArray, flashSize: "keep", flashMode: "keep", flashFreq: "keep", eraseAll: false, compress: true, // report progress reportProgress: (fileIndex: number, written: number, total: number) => { const uncompressedWritten = (written / total) * fileArray[fileIndex].data.length; const newPct = Math.floor( ((totalWritten + uncompressedWritten) / totalSize) * 100, ); // we're done with this file if (written === total) { totalWritten += uncompressedWritten; return; } fireStateEvent({ state: FlashStateType.WRITING, message: `Writing progress: ${newPct}%`, details: { bytesTotal: totalSize, bytesWritten: totalWritten + written, percentage: newPct, }, }); }, }); } catch (err: any) { fireStateEvent({ state: FlashStateType.ERROR, message: err.message, details: { error: FlashError.WRITE_FAILED, details: err }, }); await hardResetDevice(transport, esploader); await transport.disconnect(); return; } fireStateEvent({ state: FlashStateType.WRITING, message: "Writing complete", details: { bytesTotal: totalSize, bytesWritten: totalWritten, percentage: 100, }, }); await hardResetDevice(transport, esploader); console.log("DISCONNECT"); await transport.disconnect(); fireStateEvent({ state: FlashStateType.FINISHED, message: "All done!", }); }; ================================================ FILE: src/install-button.ts ================================================ import type { FlashState } from "./const"; import type { EwtInstallDialog } from "./install-dialog"; import { connect } from "./connect"; export class InstallButton extends HTMLElement { public static isSupported = "serial" in navigator; public static isAllowed = window.isSecureContext; private static style = ` button { position: relative; cursor: pointer; font-size: 14px; font-weight: 500; padding: 10px 24px; color: var(--esp-tools-button-text-color, #fff); background-color: var(--esp-tools-button-color, #03a9f4); border: none; border-radius: var(--esp-tools-button-border-radius, 9999px); } button::before { content: " "; position: absolute; top: 0; bottom: 0; left: 0; right: 0; opacity: 0.2; border-radius: var(--esp-tools-button-border-radius, 9999px); } button:hover::before { background-color: rgba(255,255,255,.8); } button:focus { outline: none; } button:focus::before { background-color: white; } button:active::before { background-color: grey; } :host([active]) button { color: rgba(0, 0, 0, 0.38); background-color: rgba(0, 0, 0, 0.12); box-shadow: none; cursor: unset; pointer-events: none; } .hidden { display: none; }`; public manifest?: string; public eraseFirst?: boolean; public hideProgress?: boolean; public showLog?: boolean; public logConsole?: boolean; public state?: FlashState; public renderRoot?: ShadowRoot; public overrides: EwtInstallDialog["overrides"]; public connectedCallback() { if (this.renderRoot) { return; } this.renderRoot = this.attachShadow({ mode: "open" }); if (!InstallButton.isSupported || !InstallButton.isAllowed) { this.toggleAttribute("install-unsupported", true); this.renderRoot.innerHTML = !InstallButton.isAllowed ? "You can only install ESP devices on HTTPS websites or on the localhost." : "Your browser does not support installing things on ESP devices. Use Google Chrome or Microsoft Edge."; return; } this.toggleAttribute("install-supported", true); const slot = document.createElement("slot"); slot.addEventListener("click", async (ev) => { ev.preventDefault(); connect(this); }); slot.name = "activate"; const button = document.createElement("button"); button.innerText = "Connect"; slot.append(button); if ( "adoptedStyleSheets" in Document.prototype && "replaceSync" in CSSStyleSheet.prototype ) { const sheet = new CSSStyleSheet(); sheet.replaceSync(InstallButton.style); this.renderRoot.adoptedStyleSheets = [sheet]; } else { const styleSheet = document.createElement("style"); styleSheet.innerText = InstallButton.style; this.renderRoot.append(styleSheet); } this.renderRoot.append(slot); } } customElements.define("esp-web-install-button", InstallButton); ================================================ FILE: src/install-dialog.ts ================================================ import { LitElement, html, PropertyValues, css, TemplateResult } from "lit"; import { state } from "lit/decorators.js"; import "./components/ew-text-button"; import "./components/ew-list"; import "./components/ew-list-item"; import "./components/ew-divider"; import "./components/ew-checkbox"; import "./components/ewt-console"; import "./components/ew-dialog"; import "./components/ew-icon-button"; import "./components/ew-filled-text-field"; import type { EwFilledTextField } from "./components/ew-filled-text-field"; import "./components/ew-filled-select"; import "./components/ew-select-option"; import "./pages/ewt-page-progress"; import "./pages/ewt-page-message"; import { closeIcon, listItemConsole, listItemEraseUserData, listItemFundDevelopment, listItemHomeAssistant, listItemInstallIcon, listItemVisitDevice, listItemWifi, refreshIcon, } from "./components/svg"; import { Logger, Manifest, FlashStateType, FlashState } from "./const.js"; import { ImprovSerial, Ssid } from "improv-wifi-serial-sdk/dist/serial"; import { ImprovSerialCurrentState, ImprovSerialErrorState, PortNotReady, } from "improv-wifi-serial-sdk/dist/const"; import { flash } from "./flash"; import { textDownload } from "./util/file-download"; import { fireEvent } from "./util/fire-event"; import { sleep } from "./util/sleep"; import { downloadManifest } from "./util/manifest"; import { dialogStyles } from "./styles"; import { version } from "./version"; import type { EwFilledSelect } from "./components/ew-filled-select"; console.log( `ESP Web Tools ${version} by Open Home Foundation; https://esphome.github.io/esp-web-tools/`, ); const ERROR_ICON = "⚠️"; const OK_ICON = "🎉"; export class EwtInstallDialog extends LitElement { public port!: SerialPort; public manifestPath!: string; public logger: Logger = console; public overrides?: { checkSameFirmware?: ( manifest: Manifest, deviceImprov: ImprovSerial["info"], ) => boolean; }; private _manifest!: Manifest; private _info?: ImprovSerial["info"]; // null = NOT_SUPPORTED @state() private _client?: ImprovSerial | null; @state() private _state: | "ERROR" | "DASHBOARD" | "PROVISION" | "INSTALL" | "ASK_ERASE" | "LOGS" = "DASHBOARD"; @state() private _installErase = false; @state() private _installConfirmed = false; @state() private _installState?: FlashState; @state() private _provisionForce = false; private _wasProvisioned = false; @state() private _error?: string; @state() private _busy = false; // undefined = not loaded // null = not available @state() private _ssids?: Ssid[] | null; // Name of Ssid. Null = other @state() private _selectedSsid: string | null = null; private _bodyOverflow: string | null = null; protected render() { if (!this.port) { return html``; } let heading: string | undefined; let content: TemplateResult; let allowClosing = false; // During installation phase we temporarily remove the client if ( this._client === undefined && this._state !== "INSTALL" && this._state !== "LOGS" ) { if (this._error) { [heading, content] = this._renderError(this._error); } else { content = this._renderProgress("Connecting"); } } else if (this._state === "INSTALL") { [heading, content, allowClosing] = this._renderInstall(); } else if (this._state === "ASK_ERASE") { [heading, content] = this._renderAskErase(); } else if (this._state === "ERROR") { [heading, content] = this._renderError(this._error!); } else if (this._state === "DASHBOARD") { [heading, content, allowClosing] = this._client ? this._renderDashboard() : this._renderDashboardNoImprov(); } else if (this._state === "PROVISION") { [heading, content] = this._renderProvision(); } else if (this._state === "LOGS") { [heading, content] = this._renderLogs(); } return html` ${heading ? html`
${heading}
` : ""} ${allowClosing ? html` ${closeIcon} ` : ""} ${content!}
`; } _renderProgress(label: string | TemplateResult, progress?: number) { return html` `; } _renderError(label: string): [string, TemplateResult] { const heading = "Error"; const content = html`
Close
`; return [heading, content]; } _renderDashboard(): [string, TemplateResult, boolean] { const heading = this._manifest.name; let content: TemplateResult; let allowClosing = true; content = html`
Connected to ${this._info!.name}
${this._info!.firmware} ${this._info!.version} (${this._info!.chipFamily})
${!this._isSameVersion ? html` { if (this._isSameFirmware) { this._startInstall(false); } else if (this._manifest.new_install_prompt_erase) { this._state = "ASK_ERASE"; } else { this._startInstall(true); } }} > ${listItemInstallIcon}
${!this._isSameFirmware ? `Install ${this._manifest.name}` : `Update ${this._manifest.name}`}
` : ""} ${this._client!.nextUrl === undefined ? "" : html` ${listItemVisitDevice}
Visit Device
`} ${!this._manifest.home_assistant_domain || this._client!.state !== ImprovSerialCurrentState.PROVISIONED ? "" : html` ${listItemHomeAssistant}
Add to Home Assistant
`} { this._state = "PROVISION"; if ( this._client!.state === ImprovSerialCurrentState.PROVISIONED ) { this._provisionForce = true; } }} > ${listItemWifi}
${this._client!.state === ImprovSerialCurrentState.READY ? "Connect to Wi-Fi" : "Change Wi-Fi"}
{ const client = this._client; if (client) { await this._closeClientWithoutEvents(client); await sleep(100); } // Also set `null` back to undefined. this._client = undefined; this._state = "LOGS"; }} > ${listItemConsole}
Logs & Console
${this._isSameFirmware && this._manifest.funding_url ? html` ${listItemFundDevelopment}
Fund Development
` : ""} ${this._isSameVersion ? html` this._startInstall(true)} > ${listItemEraseUserData}
Erase User Data
` : ""}
`; return [heading, content, allowClosing]; } _renderDashboardNoImprov(): [string, TemplateResult, boolean] { const heading = this._manifest.name; let content: TemplateResult; let allowClosing = true; content = html`
{ if (this._manifest.new_install_prompt_erase) { this._state = "ASK_ERASE"; } else { // Default is to erase a device that does not support Improv Serial this._startInstall(true); } }} > ${listItemInstallIcon}
${`Install ${this._manifest.name}`}
{ // Also set `null` back to undefined. this._client = undefined; this._state = "LOGS"; }} > ${listItemConsole}
Logs & Console
`; return [heading, content, allowClosing]; } _renderProvision(): [string | undefined, TemplateResult] { let heading: string | undefined = "Configure Wi-Fi"; let content: TemplateResult; if (this._busy) { return [ heading, this._renderProgress( this._ssids === undefined ? "Scanning for networks" : "Trying to connect", ), ]; } if ( !this._provisionForce && this._client!.state === ImprovSerialCurrentState.PROVISIONED ) { heading = undefined; const showSetupLinks = !this._wasProvisioned && (this._client!.nextUrl !== undefined || "home_assistant_domain" in this._manifest); content = html`
${showSetupLinks ? html` ${this._client!.nextUrl === undefined ? "" : html` { this._state = "DASHBOARD"; }} > ${listItemVisitDevice}
Visit Device
`} ${!this._manifest.home_assistant_domain ? "" : html` { this._state = "DASHBOARD"; }} > ${listItemHomeAssistant}
Add to Home Assistant
`} { this._state = "DASHBOARD"; }} >
Skip
` : ""}
${!showSetupLinks ? html`
{ this._state = "DASHBOARD"; }} > Continue
` : ""} `; } else { let error: string | undefined; switch (this._client!.error) { case ImprovSerialErrorState.UNABLE_TO_CONNECT: error = "Unable to connect"; break; case ImprovSerialErrorState.TIMEOUT: error = "Timeout"; break; case ImprovSerialErrorState.NO_ERROR: // Happens when list SSIDs not supported. case ImprovSerialErrorState.UNKNOWN_RPC_COMMAND: break; default: error = `Unknown error (${this._client!.error})`; } const selectedSsid = this._ssids?.find( (info) => info.name === this._selectedSsid, ); content = html` ${refreshIcon}
Connect your device to the network to start using it.
${error ? html`

${error}

` : ""} ${this._ssids !== null ? html` { const index = ev.target.selectedIndex; // The "Join Other" item is always the last item. this._selectedSsid = index === this._ssids!.length ? null : this._ssids![index].name; }} > ${this._ssids!.map( (info) => html` ${info.name} `, )} Join other… ` : ""} ${ // Show input box if command not supported or "Join Other" selected !selectedSsid ? html` ` : "" } ${!selectedSsid || selectedSsid.secured ? html` ` : ""}
{ this._state = "DASHBOARD"; }} > ${this._installState && this._installErase ? "Skip" : "Back"} Connect
`; } return [heading, content]; } _renderAskErase(): [string | undefined, TemplateResult] { const heading = "Erase device"; const content = html`
Do you want to erase the device before installing ${this._manifest.name}? All data on the device will be lost.
{ this._state = "DASHBOARD"; }} > Back { const checkbox = this.shadowRoot!.querySelector("ew-checkbox")!; this._startInstall(checkbox.checked); }} > Next
`; return [heading, content]; } _renderInstall(): [string | undefined, TemplateResult, boolean] { let heading: string | undefined; let content: TemplateResult; const allowClosing = false; const isUpdate = !this._installErase && this._isSameFirmware; if (!this._installConfirmed && this._isSameVersion) { heading = "Erase User Data"; content = html`
Do you want to reset your device and erase all user data from your device?
Erase User Data
`; } else if (!this._installConfirmed) { heading = "Confirm Installation"; const action = isUpdate ? "update to" : "install"; content = html`
${isUpdate ? html`Your device is running ${this._info!.firmware} ${this._info!.version}.

` : ""} Do you want to ${action} ${this._manifest.name} ${this._manifest.version}? ${this._installErase ? html`

All data on the device will be erased.` : ""}
{ this._state = "DASHBOARD"; }} > Back Install
`; } else if ( !this._installState || this._installState.state === FlashStateType.INITIALIZING || this._installState.state === FlashStateType.PREPARING ) { heading = "Installing"; content = this._renderProgress("Preparing installation"); } else if (this._installState.state === FlashStateType.ERASING) { heading = "Installing"; content = this._renderProgress("Erasing"); } else if ( this._installState.state === FlashStateType.WRITING || // When we're finished, keep showing this screen with 100% written // until Improv is initialized / not detected. (this._installState.state === FlashStateType.FINISHED && this._client === undefined) ) { heading = "Installing"; let percentage: number | undefined; let undeterminateLabel: string | undefined; if (this._installState.state === FlashStateType.FINISHED) { // We're done writing and detecting improv, show spinner undeterminateLabel = "Wrapping up"; } else if (this._installState.details.percentage < 4) { // We're writing the firmware under 4%, show spinner or else we don't show any pixels undeterminateLabel = "Installing"; } else { // We're writing the firmware over 4%, show progress bar percentage = this._installState.details.percentage; } content = this._renderProgress( html` ${undeterminateLabel ? html`${undeterminateLabel}
` : ""}
This will take ${this._installState.chipFamily === "ESP8266" ? "a minute" : "2 minutes"}.
Keep this page visible to prevent slow down `, percentage, ); } else if (this._installState.state === FlashStateType.FINISHED) { heading = undefined; const supportsImprov = this._client !== null; content = html`
{ this._state = supportsImprov && this._installErase ? "PROVISION" : "DASHBOARD"; }} > Next
`; } else if (this._installState.state === FlashStateType.ERROR) { heading = "Installation failed"; content = html`
{ this._initialize(); this._state = "DASHBOARD"; }} > Back
`; } return [heading, content!, allowClosing]; } _renderLogs(): [string | undefined, TemplateResult] { let heading: string | undefined = `Logs`; let content: TemplateResult; content = html`
{ await this.shadowRoot!.querySelector("ewt-console")!.reset(); }} > Reset Device { textDownload( this.shadowRoot!.querySelector("ewt-console")!.logs(), `esp-web-tools-logs.txt`, ); this.shadowRoot!.querySelector("ewt-console")!.reset(); }} > Download Logs { await this.shadowRoot!.querySelector("ewt-console")!.disconnect(); this._state = "DASHBOARD"; this._initialize(); }} > Back
`; return [heading, content!]; } public override willUpdate(changedProps: PropertyValues) { if (!changedProps.has("_state")) { return; } // Clear errors when changing between pages unless we change // to the error page. if (this._state !== "ERROR") { this._error = undefined; } // Scan for SSIDs on provision if (this._state === "PROVISION") { this._updateSsids(); } else { // Reset this value if we leave provisioning. this._provisionForce = false; } if (this._state === "INSTALL") { this._installConfirmed = false; this._installState = undefined; } } private async _updateSsids(tries = 0) { const oldSsids = this._ssids; this._ssids = undefined; this._busy = true; let ssids: Ssid[]; try { ssids = await this._client!.scan(); } catch (err) { // When we fail while loading, pick "Join other" if (this._ssids === undefined) { this._ssids = null; this._selectedSsid = null; } this._busy = false; return; } // We will retry a few times if we don't get any results if (ssids.length === 0 && tries < 3) { console.log("SCHEDULE RETRY", tries); setTimeout(() => this._updateSsids(tries + 1), 2000); return; } if (oldSsids) { // If we had a previous list, ensure the selection is still valid if ( this._selectedSsid && !ssids.find((s) => s.name === this._selectedSsid) ) { this._selectedSsid = ssids[0].name; } } else { this._selectedSsid = ssids.length ? ssids[0].name : null; } this._ssids = ssids; this._busy = false; } protected override firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); this._bodyOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; this._initialize(); } protected override updated(changedProps: PropertyValues) { super.updated(changedProps); if (changedProps.has("_state")) { this.setAttribute("state", this._state); } if (this._state !== "PROVISION") { return; } if (changedProps.has("_selectedSsid") && this._selectedSsid === null) { // If we pick "Join other", select SSID input. this._focusFormElement("ew-filled-text-field[name=ssid]"); } else if (changedProps.has("_ssids")) { // Form is shown when SSIDs are loaded/marked not supported this._focusFormElement(); } } private _focusFormElement( selector = "ew-filled-text-field, ew-filled-select", ) { const formEl = this.shadowRoot!.querySelector( selector, ) as LitElement | null; if (formEl) { formEl.updateComplete.then(() => setTimeout(() => formEl.focus(), 100)); } } private async _initialize(justInstalled = false) { if (this.port.readable === null || this.port.writable === null) { this._state = "ERROR"; this._error = "Serial port is not readable/writable. Close any other application using it and try again."; return; } try { this._manifest = await downloadManifest(this.manifestPath); } catch (err: any) { this._state = "ERROR"; this._error = "Failed to download manifest"; return; } if (this._manifest.new_install_improv_wait_time === 0) { this._client = null; return; } const client = new ImprovSerial(this.port!, this.logger); client.addEventListener("state-changed", () => { this.requestUpdate(); }); client.addEventListener("error-changed", () => this.requestUpdate()); try { // If a device was just installed, give new firmware 10 seconds (overridable) to // format the rest of the flash and do other stuff. const timeout = !justInstalled ? 1000 : this._manifest.new_install_improv_wait_time !== undefined ? this._manifest.new_install_improv_wait_time * 1000 : 10000; this._info = await client.initialize(timeout); this._client = client; client.addEventListener("disconnect", this._handleDisconnect); } catch (err: any) { // Clear old value this._info = undefined; if (err instanceof PortNotReady) { this._state = "ERROR"; this._error = "Serial port is not ready. Close any other application using it and try again."; } else { this._client = null; // not supported this.logger.error("Improv initialization failed.", err); } } } private _startInstall(erase: boolean) { this._state = "INSTALL"; this._installErase = erase; this._installConfirmed = false; } private async _confirmInstall() { this._installConfirmed = true; this._installState = undefined; if (this._client) { await this._closeClientWithoutEvents(this._client); } this._client = undefined; // Close port. ESPLoader likes opening it. await this.port.close(); flash( (state) => { this._installState = state; if (state.state === FlashStateType.FINISHED) { sleep(100) // Flashing closes the port .then(() => this.port.open({ baudRate: 115200, bufferSize: 8192 })) .then(() => this._initialize(true)) .then(() => this.requestUpdate()); } else if (state.state === FlashStateType.ERROR) { sleep(100) // Flashing closes the port .then(() => this.port.open({ baudRate: 115200, bufferSize: 8192 })); } }, this.port, this.manifestPath, this._manifest, this._installErase, ); } private async _doProvision() { this._busy = true; this._wasProvisioned = this._client!.state === ImprovSerialCurrentState.PROVISIONED; const ssid = this._selectedSsid === null ? ( this.shadowRoot!.querySelector( "ew-filled-text-field[name=ssid]", ) as EwFilledTextField ).value : this._selectedSsid; const password = ( this.shadowRoot!.querySelector( "ew-filled-text-field[name=password]", ) as EwFilledTextField | null )?.value || ""; try { await this._client!.provision(ssid, password, 30000); } catch (err: any) { return; } finally { this._busy = false; this._provisionForce = false; } } private _handleDisconnect = () => { this._state = "ERROR"; this._error = "Disconnected"; }; private _closeDialog() { this.shadowRoot!.querySelector("ew-dialog")!.close(); } private async _handleClose() { if (this._client) { await this._closeClientWithoutEvents(this._client); } fireEvent(this, "closed" as any); document.body.style.overflow = this._bodyOverflow!; this.parentNode!.removeChild(this); } /** * Return if the device runs same firmware as manifest. */ private get _isSameFirmware() { return !this._info ? false : this.overrides?.checkSameFirmware ? this.overrides.checkSameFirmware(this._manifest, this._info) : this._info.firmware === this._manifest.name; } /** * Return if the device runs same firmware and version as manifest. */ private get _isSameVersion() { return ( this._isSameFirmware && this._info!.version === this._manifest.version ); } private async _closeClientWithoutEvents(client: ImprovSerial) { client.removeEventListener("disconnect", this._handleDisconnect); await client.close(); } private _preventDefault(ev: Event) { ev.preventDefault(); } static styles = [ dialogStyles, css` :host { --mdc-dialog-max-width: 390px; } div[slot="headline"] { padding-right: 48px; } ew-icon-button[slot="headline"] { position: absolute; right: 4px; top: 8px; } ew-icon-button[slot="headline"] svg { padding: 8px; color: var(--text-color); } .dialog-nav svg { color: var(--text-color); } .table-row { display: flex; } .table-row.last { margin-bottom: 16px; } .table-row svg { width: 20px; margin-right: 8px; } ew-filled-text-field, ew-filled-select { display: block; margin-top: 16px; } label.formfield { display: inline-flex; align-items: center; padding-right: 8px; } ew-list { margin: 0 -24px; padding: 0; } ew-list-item svg { height: 24px; } ewt-page-message + ew-list { padding-top: 16px; } .fake-icon { width: 24px; } .error { color: var(--danger-color); } .danger { --mdc-theme-primary: var(--danger-color); --mdc-theme-secondary: var(--danger-color); --md-sys-color-primary: var(--danger-color); --md-sys-color-on-surface: var(--danger-color); } button.link { background: none; color: inherit; border: none; padding: 0; font: inherit; text-align: left; text-decoration: underline; cursor: pointer; } :host([state="LOGS"]) ew-dialog { max-width: 90vw; max-height: 90vh; } ewt-console { width: calc(80vw - 48px); height: calc(90vh - 168px); } `, ]; } customElements.define("ewt-install-dialog", EwtInstallDialog); declare global { interface HTMLElementTagNameMap { "ewt-install-dialog": EwtInstallDialog; } } ================================================ FILE: src/no-port-picked/index.ts ================================================ import "./no-port-picked-dialog"; export const openNoPortPickedDialog = async ( doTryAgain?: () => void, ): Promise => { const dialog = document.createElement("ewt-no-port-picked-dialog"); dialog.doTryAgain = doTryAgain; document.body.append(dialog); return true; }; ================================================ FILE: src/no-port-picked/no-port-picked-dialog.ts ================================================ import { LitElement, html, css, svg } from "lit"; import { customElement } from "lit/decorators.js"; import "../components/ew-dialog"; import "../components/ew-text-button"; import { dialogStyles } from "../styles"; import { getOperatingSystem } from "../util/get-operating-system"; const cloudDownload = svg` `; @customElement("ewt-no-port-picked-dialog") class EwtNoPortPickedDialog extends LitElement { public doTryAgain?: () => void; public render() { const OS = getOperatingSystem(); return html`
No port selected
If you didn't select a port because you didn't see your device listed, try the following steps:
  1. Make sure that the device is connected to this computer (the one that runs the browser that shows this website)
  2. Most devices have a tiny light when it is powered on. If yours has one, make sure it is on.
  3. Make sure that the USB cable you use can be used for data and is not a power-only cable.
  4. ${OS === "Linux" ? html`
  5. If you are using a Linux flavor, make sure that your user is part of the dialout group so it has permission to access the device. sudo usermod -a -G dialout YourUserName You may need to log out & back in or reboot to activate the new group access.
  6. ` : ""}
  7. Make sure you have the right drivers installed. Below are the drivers for common chips used in ESP devices:
    • CP2102 drivers: Windows & Mac
    • CH342, CH343, CH9102 drivers: Windows, Mac
      (download via blue button with ${cloudDownload} icon)
    • CH340, CH341 drivers: Windows, Mac
      (download via blue button with ${cloudDownload} icon)
${this.doTryAgain ? html` Cancel Try Again ` : html` Close `}
`; } private tryAgain() { this.close(); this.doTryAgain?.(); } private close() { this.shadowRoot!.querySelector("ew-dialog")!.close(); } private async _handleClose() { this.parentNode!.removeChild(this); } static styles = [ dialogStyles, css` li + li, li > ul { margin-top: 8px; } ul, ol { margin-bottom: 0; padding-left: 1.5em; } li code.block { display: block; margin: 0.5em 0; } `, ]; } declare global { interface HTMLElementTagNameMap { "ewt-no-port-picked-dialog": EwtNoPortPickedDialog; } } ================================================ FILE: src/pages/ewt-page-message.ts ================================================ import { LitElement, html, css, TemplateResult } from "lit"; import { property } from "lit/decorators.js"; class EwtPageMessage extends LitElement { @property() icon!: string; @property() label!: string | TemplateResult; render() { return html`
${this.icon}
${this.label} `; } static styles = css` :host { display: flex; flex-direction: column; text-align: center; } .icon { font-size: 50px; line-height: 80px; color: black; } `; } customElements.define("ewt-page-message", EwtPageMessage); declare global { interface HTMLElementTagNameMap { "ewt-page-message": EwtPageMessage; } } ================================================ FILE: src/pages/ewt-page-progress.ts ================================================ import { LitElement, html, css, TemplateResult } from "lit"; import { property } from "lit/decorators.js"; import "../components/ew-circular-progress"; class EwtPageProgress extends LitElement { @property() label!: string | TemplateResult; @property() progress: number | undefined; render() { return html`
${this.progress !== undefined ? html`
${this.progress}%
` : ""}
${this.label} `; } static styles = css` :host { display: flex; flex-direction: column; text-align: center; } ew-circular-progress { margin-bottom: 16px; } `; } customElements.define("ewt-page-progress", EwtPageProgress); declare global { interface HTMLElementTagNameMap { "ewt-page-progress": EwtPageProgress; } } ================================================ FILE: src/styles.ts ================================================ import { css } from "lit"; // We set font-size to 16px and all the mdc typography styles // because it defaults to rem, which means that the font-size // of the host website would influence the ESP Web Tools dialog. export const dialogStyles = css` :host { --roboto-font: Roboto, system-ui; --text-color: rgba(0, 0, 0, 0.6); --danger-color: #db4437; --md-sys-color-primary: #03a9f4; --md-sys-color-on-primary: #fff; --md-ref-typeface-brand: var(--roboto-font); --md-ref-typeface-plain: var(--roboto-font); --md-sys-color-surface: #fff; --md-sys-color-surface-container: #fff; --md-sys-color-surface-container-high: #fff; --md-sys-color-surface-container-highest: #f5f5f5; --md-sys-color-secondary-container: #e0e0e0; --md-sys-typescale-headline-font: var(--roboto-font); --md-sys-typescale-title-font: var(--roboto-font); } a { color: var(--md-sys-color-primary); } `; ================================================ FILE: src/util/console-color.ts ================================================ interface ConsoleState { bold: boolean; italic: boolean; underline: boolean; strikethrough: boolean; foregroundColor: string | null; backgroundColor: string | null; carriageReturn: boolean; lines: string[]; secret: boolean; } export class ColoredConsole { public state: ConsoleState = { bold: false, italic: false, underline: false, strikethrough: false, foregroundColor: null, backgroundColor: null, carriageReturn: false, lines: [], secret: false, }; constructor(public targetElement: HTMLElement) {} logs(): string { return this.targetElement.innerText; } processLine(line: string): Element { // @ts-expect-error const re = /(?:\033|\\033)(?:\[(.*?)[@-~]|\].*?(?:\007|\033\\))/g; let i = 0; const lineSpan = document.createElement("span"); lineSpan.classList.add("line"); const addSpan = (content: string) => { if (content === "") return; const span = document.createElement("span"); if (this.state.bold) span.classList.add("log-bold"); if (this.state.italic) span.classList.add("log-italic"); if (this.state.underline) span.classList.add("log-underline"); if (this.state.strikethrough) span.classList.add("log-strikethrough"); if (this.state.secret) span.classList.add("log-secret"); if (this.state.foregroundColor !== null) span.classList.add(`log-fg-${this.state.foregroundColor}`); if (this.state.backgroundColor !== null) span.classList.add(`log-bg-${this.state.backgroundColor}`); span.appendChild(document.createTextNode(content)); lineSpan.appendChild(span); if (this.state.secret) { const redacted = document.createElement("span"); redacted.classList.add("log-secret-redacted"); redacted.appendChild(document.createTextNode("[redacted]")); lineSpan.appendChild(redacted); } }; while (true) { const match = re.exec(line); if (match === null) break; const j = match.index; addSpan(line.substring(i, j)); i = j + match[0].length; if (match[1] === undefined) continue; for (const colorCode of match[1].split(";")) { switch (parseInt(colorCode)) { case 0: // reset this.state.bold = false; this.state.italic = false; this.state.underline = false; this.state.strikethrough = false; this.state.foregroundColor = null; this.state.backgroundColor = null; this.state.secret = false; break; case 1: this.state.bold = true; break; case 3: this.state.italic = true; break; case 4: this.state.underline = true; break; case 5: this.state.secret = true; break; case 6: this.state.secret = false; break; case 9: this.state.strikethrough = true; break; case 22: this.state.bold = false; break; case 23: this.state.italic = false; break; case 24: this.state.underline = false; break; case 29: this.state.strikethrough = false; break; case 30: this.state.foregroundColor = "black"; break; case 31: this.state.foregroundColor = "red"; break; case 32: this.state.foregroundColor = "green"; break; case 33: this.state.foregroundColor = "yellow"; break; case 34: this.state.foregroundColor = "blue"; break; case 35: this.state.foregroundColor = "magenta"; break; case 36: this.state.foregroundColor = "cyan"; break; case 37: this.state.foregroundColor = "white"; break; case 39: this.state.foregroundColor = null; break; case 41: this.state.backgroundColor = "red"; break; case 42: this.state.backgroundColor = "green"; break; case 43: this.state.backgroundColor = "yellow"; break; case 44: this.state.backgroundColor = "blue"; break; case 45: this.state.backgroundColor = "magenta"; break; case 46: this.state.backgroundColor = "cyan"; break; case 47: this.state.backgroundColor = "white"; break; case 40: case 49: this.state.backgroundColor = null; break; } } } addSpan(line.substring(i)); return lineSpan; } processLines() { const atBottom = this.targetElement.scrollTop > this.targetElement.scrollHeight - this.targetElement.offsetHeight - 50; const prevCarriageReturn = this.state.carriageReturn; const fragment = document.createDocumentFragment(); if (this.state.lines.length == 0) { return; } for (const line of this.state.lines) { if (this.state.carriageReturn && line !== "\n") { if (fragment.childElementCount) { fragment.removeChild(fragment.lastChild!); } } fragment.appendChild(this.processLine(line)); this.state.carriageReturn = line.includes("\r"); } if (prevCarriageReturn && this.state.lines[0] !== "\n") { this.targetElement.replaceChild(fragment, this.targetElement.lastChild!); } else { this.targetElement.appendChild(fragment); } this.state.lines = []; // Keep scroll at bottom if (atBottom) { this.targetElement.scrollTop = this.targetElement.scrollHeight; } } addLine(line: string) { // Processing of lines is deferred for performance reasons if (this.state.lines.length == 0) { setTimeout(() => this.processLines(), 0); } this.state.lines.push(line); } } export const coloredConsoleStyles = ` .log { flex: 1; background-color: #1c1c1c; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 12px; padding: 16px; overflow: auto; line-height: 1.45; border-radius: 3px; white-space: pre-wrap; overflow-wrap: break-word; color: #ddd; } .log-bold { font-weight: bold; } .log-italic { font-style: italic; } .log-underline { text-decoration: underline; } .log-strikethrough { text-decoration: line-through; } .log-underline.log-strikethrough { text-decoration: underline line-through; } .log-secret { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } .log-secret-redacted { opacity: 0; width: 1px; font-size: 1px; } .log-fg-black { color: rgb(128, 128, 128); } .log-fg-red { color: rgb(255, 0, 0); } .log-fg-green { color: rgb(0, 255, 0); } .log-fg-yellow { color: rgb(255, 255, 0); } .log-fg-blue { color: rgb(0, 0, 255); } .log-fg-magenta { color: rgb(255, 0, 255); } .log-fg-cyan { color: rgb(0, 255, 255); } .log-fg-white { color: rgb(187, 187, 187); } .log-bg-black { background-color: rgb(0, 0, 0); } .log-bg-red { background-color: rgb(255, 0, 0); } .log-bg-green { background-color: rgb(0, 255, 0); } .log-bg-yellow { background-color: rgb(255, 255, 0); } .log-bg-blue { background-color: rgb(0, 0, 255); } .log-bg-magenta { background-color: rgb(255, 0, 255); } .log-bg-cyan { background-color: rgb(0, 255, 255); } .log-bg-white { background-color: rgb(255, 255, 255); } `; ================================================ FILE: src/util/file-download.ts ================================================ export const fileDownload = (href: string, filename = ""): void => { const a = document.createElement("a"); a.target = "_blank"; a.href = href; a.download = filename; document.body.appendChild(a); a.dispatchEvent(new MouseEvent("click")); document.body.removeChild(a); }; export const textDownload = (text: string, filename = ""): void => { const blob = new Blob([text], { type: "text/plain" }); const url = URL.createObjectURL(blob); fileDownload(url, filename); setTimeout(() => URL.revokeObjectURL(url), 0); }; ================================================ FILE: src/util/fire-event.ts ================================================ export const fireEvent = ( eventTarget: EventTarget, type: Event, // @ts-ignore detail?: HTMLElementEventMap[Event]["detail"], options?: { bubbles?: boolean; cancelable?: boolean; composed?: boolean; }, ): void => { options = options || {}; const event = new CustomEvent(type, { bubbles: options.bubbles === undefined ? true : options.bubbles, cancelable: Boolean(options.cancelable), composed: options.composed === undefined ? true : options.composed, detail, }); eventTarget.dispatchEvent(event); }; ================================================ FILE: src/util/get-operating-system.ts ================================================ // From https://stackoverflow.com/a/38241481 export const getOperatingSystem = () => { const userAgent = window.navigator.userAgent; const platform = // @ts-expect-error window.navigator?.userAgentData?.platform || window.navigator.platform; const macosPlatforms = ["macOS", "Macintosh", "MacIntel", "MacPPC", "Mac68K"]; const windowsPlatforms = ["Win32", "Win64", "Windows", "WinCE"]; const iosPlatforms = ["iPhone", "iPad", "iPod"]; if (macosPlatforms.indexOf(platform) !== -1) { return "Mac OS"; } else if (iosPlatforms.indexOf(platform) !== -1) { return "iOS"; } else if (windowsPlatforms.indexOf(platform) !== -1) { return "Windows"; } else if (/Android/.test(userAgent)) { return "Android"; } else if (/Linux/.test(platform)) { return "Linux"; } return null; }; ================================================ FILE: src/util/line-break-transformer.ts ================================================ export class LineBreakTransformer implements Transformer { private chunks = ""; transform( chunk: string, controller: TransformStreamDefaultController, ) { // Append new chunks to existing chunks. this.chunks += chunk; // For each line breaks in chunks, send the parsed lines out. const lines = this.chunks.split(/\r?\n/); this.chunks = lines.pop()!; lines.forEach((line) => controller.enqueue(line + "\r\n")); } flush(controller: TransformStreamDefaultController) { // When the stream is closed, flush any remaining chunks out. controller.enqueue(this.chunks); } } ================================================ FILE: src/util/manifest.ts ================================================ import { Manifest } from "../const"; export const downloadManifest = async (manifestPath: string) => { const manifestURL = new URL(manifestPath, location.toString()).toString(); const resp = await fetch(manifestURL); const manifest: Manifest = await resp.json(); if ("new_install_skip_erase" in manifest) { console.warn( 'Manifest option "new_install_skip_erase" is deprecated. Use "new_install_prompt_erase" instead.', ); if (manifest.new_install_skip_erase) { manifest.new_install_prompt_erase = true; } } return manifest; }; ================================================ FILE: src/util/sleep.ts ================================================ export const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)); ================================================ FILE: src/util/timestamp-transformer.ts ================================================ export class TimestampTransformer implements Transformer { transform( chunk: string, controller: TransformStreamDefaultController, ) { const date = new Date(); const h = date.getHours().toString().padStart(2, "0"); const m = date.getMinutes().toString().padStart(2, "0"); const s = date.getSeconds().toString().padStart(2, "0"); controller.enqueue(`[${h}:${m}:${s}]${chunk}`); } } ================================================ FILE: src/version.ts ================================================ export const version = "dev"; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "lib": ["es2019", "dom"], "target": "es2019", "module": "es2020", "moduleResolution": "node", "resolveJsonModule": true, "outDir": "dist", "declaration": true, "experimentalDecorators": true, "noFallthroughCasesInSwitch": true, "noImplicitReturns": true, "noUnusedLocals": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, "importHelpers": true }, "include": ["src/*"] }