From 357b5f9303fbd75f511dc7590aa7c7211b2cce78 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 30 May 2021 22:27:47 -0700 Subject: [PATCH] Rewrite log element --- README.md | 27 ++++++- example.html | 6 +- rollup.config.js | 3 +- src/flash-button.ts | 43 ----------- src/flash-log.ts | 163 ++++++++++++++---------------------------- src/install-button.ts | 52 ++++++++++++++ src/start-flash.ts | 115 ++++++++++++++++++++--------- 7 files changed, 220 insertions(+), 189 deletions(-) delete mode 100644 src/flash-button.ts create mode 100644 src/install-button.ts diff --git a/README.md b/README.md index 786897b..7191a66 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # JavaScript SDK for ESPHome -Allow flashing ESPHome or other ESP-based firmwares via the browser. +Allow flashing ESPHome or other ESP-based firmwares via the browser. Will automatically detect the board type and select a supported firmware. -Defined using a manifest. +```html + +``` + +Manifest definition: ```json { @@ -17,11 +23,28 @@ Defined using a manifest. { "filename": "ota.bin", "offset": 57344 }, { "filename": "firmware.bin", "offset": 65536 } ] + }, + { + "chipFamily": "ESP8266", + "parts": [ + { "filename": "esp8266.bin", "offset": 0 }, + ] } ] } ``` +Allows for optionally passing an attribute to trigger an erase before installation. + +```html + +``` + +All attributes can also be set via properties (`manifest`, `eraseFirst`) + ## Development Run `script/develop`. This starts a server. Open it on http://localhost:5000. diff --git a/example.html b/example.html index 7b02732..710afe4 100644 --- a/example.html +++ b/example.html @@ -17,13 +17,13 @@

ESPHome Web is a set of tools to allow working with ESP devices in the browser.

To flash the XX firmware, connect an ESP to your computer and hit the button:

- + >

Note, this only works in desktop Chrome and Edge. Android support has not been implemented yet.

This works by combining Web Serial with a manifest which describes the firmware. It will automatically detect the type of the connected ESP device and find the right firmware files in the manifest.

- + diff --git a/rollup.config.js b/rollup.config.js index 821acac..c7c32d6 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -3,11 +3,12 @@ import json from "@rollup/plugin-json"; import { terser } from "rollup-plugin-terser"; const config = { - input: "dist/flash-button.js", + 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: [nodeResolve(), json()], }; diff --git a/src/flash-button.ts b/src/flash-button.ts deleted file mode 100644 index 52ee9e7..0000000 --- a/src/flash-button.ts +++ /dev/null @@ -1,43 +0,0 @@ -import "./vendor/esptool"; - -class FlashButton extends HTMLElement { - public static isSupported = "serial" in navigator; - - private renderRoot?: ShadowRoot; - - public connectedCallback() { - if (this.renderRoot) { - return; - } - - this.renderRoot = this.attachShadow({ mode: "open" }); - - if (FlashButton.isSupported) { - this.addEventListener("mouseover", () => { - // Preload - import("./start-flash"); - }); - this.addEventListener("click", async (ev) => { - ev.preventDefault(); - const manifest = this.getAttribute("manifest"); - if (!manifest) { - alert("No manifest defined!"); - return; - } - - const mod = await import("./start-flash"); - - const progress = document.createElement("div"); - document.body.append(progress); - - await mod.startFlash(console, manifest, progress); - }); - } - - this.renderRoot.innerHTML = FlashButton.isSupported - ? "" - : "Your browser does not support flashing ESP devices. Use Google Chrome or Microsoft Edge."; - } -} - -customElements.define("esphome-web-flash-button", FlashButton); diff --git a/src/flash-log.ts b/src/flash-log.ts index 59a1a1c..a16713b 100644 --- a/src/flash-log.ts +++ b/src/flash-log.ts @@ -1,121 +1,64 @@ -import { css, html, HTMLTemplateResult, LitElement, PropertyValues } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { Manifest } from "./const"; -import { getChipFamilyName } from "./util"; -import { ESPLoader } from "./vendor/esptool/esp_loader"; +import { css, html, HTMLTemplateResult, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; + +interface Row { + id?: string; + content: HTMLTemplateResult | string; + error?: boolean; + action?: boolean; +} @customElement("esphome-web-flash-log") class FlashLog extends LitElement { - @property() public offerImprov = false; + @state() _rows: Row[] = []; - @property() public esploader?: ESPLoader; - - @property() public manifest?: Manifest; - - @property() public totalBytes?: number; - - @property() public bytesWritten?: number; - - @property() public extraMsg: string = ""; - - @property() public errorMsg: string = ""; - - @property() public allowClose = false; - - render() { - if (!this.esploader) { - return this._renderBody(["Establishing connection..."]); - } - - const lines: Array = [ - html`Connection established
`, - ]; - - if (!this.esploader.chipFamily) { - lines.push("Initializing..."); - return this._renderBody(lines); - } - - lines.push( - html`Initialized. Found ${getChipFamilyName(this.esploader)}
` - ); - - if (this.manifest === undefined) { - lines.push(html`Fetching manifest...
`); - return this._renderBody(lines); - } - - lines.push(html`Found manifest for ${this.manifest.name}
`); - - if (!this.totalBytes) { - return this._renderBody(lines); - } - - lines.push(html`Bytes to be written: ${this.totalBytes}
`); - - if (!this.bytesWritten) { - return this._renderBody(lines); - } - - if (this.bytesWritten !== this.totalBytes) { - lines.push( - html`Writing progress: - ${Math.floor((this.bytesWritten / this.totalBytes) * 100)}%
` - ); - return this._renderBody(lines); - } - - const doImprov = - this.offerImprov && - customElements.get("improv-wifi-launch-button")?.isSupported; - - lines.push(html`Writing complete${doImprov ? "" : ", all done!"}
`); - - if (doImprov) { - lines.push(html` -
- + html`
- `); - } - - return this._renderBody(lines, !doImprov); + ${row.content} +
` + )}`; } - private _renderBody( - lines: Array, - allowClose = false - ) { - // allow closing if esploader not connected - // or we are at the end. - // TODO force allow close if not connected - return html` - ${lines} ${this.extraMsg} - ${allowClose - ? html`
` - : ""} - ${this.errorMsg - ? html`
Error: ${this.errorMsg}
` - : ""} - ${this.esploader && !this.esploader.connected - ? html`
Connection lost
` - : ""} - `; - } - - protected updated(props: PropertyValues) { - super.updated(props); - - if (props.has("esploader") && this.esploader) { - this.esploader.addEventListener("disconnect", () => this.requestUpdate()); + /** + * Add or replace a row. + */ + public addRow(row: Row) { + // If last entry has same ID, replace it. + if ( + row.id && + this._rows.length > 0 && + this._rows[this._rows.length - 1].id === row.id + ) { + const newRows = this._rows.slice(0, -1); + newRows.push(row); + this._rows = newRows; + } else { + this._rows = [...this._rows, row]; } } - private _close() { - this.parentElement?.removeChild(this); + /** + * Add an error row + */ + public addError(content: Row["content"]) { + this.addRow({ content, error: true }); + } + + /** + * Remove last row if ID matches + */ + public removeRow(id: string) { + if (this._rows.length > 0 && this._rows[this._rows.length - 1].id === id) { + this._rows = this._rows.slice(0, -1); + } } static styles = css` @@ -141,8 +84,12 @@ class FlashLog extends LitElement { cursor: pointer; } + .action, .error { margin-top: 1em; + } + + .error { color: red; } `; diff --git a/src/install-button.ts b/src/install-button.ts new file mode 100644 index 0000000..65b600b --- /dev/null +++ b/src/install-button.ts @@ -0,0 +1,52 @@ +class InstallButton extends HTMLElement { + public static isSupported = "serial" in navigator; + + public eraseFirst?: boolean; + + private renderRoot?: ShadowRoot; + + public connectedCallback() { + if (this.renderRoot) { + return; + } + + this.renderRoot = this.attachShadow({ mode: "open" }); + + if (!InstallButton.isSupported) { + this.renderRoot.innerHTML = + "Your browser does not support installing things on ESP devices. Use Google Chrome or Microsoft Edge."; + return; + } + + this.addEventListener("mouseover", () => { + // Preload + import("./start-flash"); + }); + this.addEventListener("click", async (ev) => { + ev.preventDefault(); + const manifest = this.getAttribute("manifest"); + if (!manifest) { + alert("No manifest defined!"); + return; + } + + const mod = await import("./start-flash"); + + const progress = document.createElement("div"); + document.body.append(progress); + + await mod.startFlash( + console, + manifest, + progress, + this.eraseFirst !== undefined + ? this.eraseFirst + : this.hasAttribute("erase-first") + ); + }); + + this.renderRoot.innerHTML = ``; + } +} + +customElements.define("esphome-web-install-button", InstallButton); diff --git a/src/start-flash.ts b/src/start-flash.ts index 55a92a1..f75d9cc 100644 --- a/src/start-flash.ts +++ b/src/start-flash.ts @@ -1,25 +1,23 @@ -import { Build, Manifest } from "./const"; +import { html } from "lit"; import { connect } from "./vendor/esptool"; -import { Logger } from "./vendor/esptool/const"; -import { ESPLoader } from "./vendor/esptool/esp_loader"; +import type { Logger } from "./vendor/esptool/const"; +import type { ESPLoader } from "./vendor/esptool/esp_loader"; +import { Build, Manifest } from "./const"; import "./flash-log"; import { getChipFamilyName } from "./util"; export const startFlash = async ( logger: Logger, manifestPath: string, - logParent: HTMLElement + logParent: HTMLElement, + eraseFirst: boolean ) => { const manifestURL = new URL(manifestPath, location.toString()).toString(); const manifestProm = fetch(manifestURL).then( (resp): Promise => resp.json() ); - let bytesWritten = 0; - let totalSize = 0; - let esploader: ESPLoader | undefined; - let manifest: Manifest | undefined; try { esploader = await connect(logger); @@ -28,8 +26,12 @@ export const startFlash = async ( return; } + // For debugging + (window as any).esploader = esploader; + const logEl = document.createElement("esphome-web-flash-log"); - logEl.esploader = esploader; + // logEl.esploader = esploader; + logEl.addRow({ id: "initializing", content: "Initializing..." }); logParent.append(logEl); try { @@ -37,25 +39,33 @@ export const startFlash = async ( } catch (err) { console.error(err); if (esploader.connected) { - logEl.errorMsg = - "Failed to initialize. Try resetting your device or holding the BOOT button before clicking connect."; + logEl.addError( + "Failed to initialize. Try resetting your device or holding the BOOT button before clicking connect." + ); await esploader.disconnect(); } return; } - // To reflect initialized status - logEl.requestUpdate(); + logEl.addRow({ + id: "initializing", + content: html`Initialized. Found ${getChipFamilyName(esploader)}`, + }); + logEl.addRow({ id: "manifest", content: "Fetching manifest..." }); + let manifest: Manifest | undefined; try { manifest = await manifestProm; } catch (err) { - logEl.errorMsg = `Unable to fetch manifest: ${err}`; + logEl.addError(`Unable to fetch manifest: ${err}`); await esploader.disconnect(); return; } - logEl.manifest = manifest; + logEl.addRow({ + id: "manifest", + content: html`Found manifest for ${manifest.name}`, + }); const chipFamily = getChipFamilyName(esploader); @@ -68,21 +78,15 @@ export const startFlash = async ( } if (!build) { - logEl.errorMsg = `Your ${chipFamily} board is not supported.`; + logEl.addError(`Your ${chipFamily} board is not supported.`); await esploader.disconnect(); return; } - logEl.offerImprov = build.improv; - logEl.extraMsg = "Preparing installation..."; - - // Pre-load improv for later - if (build.improv) { - // @ts-ignore - import("https://www.improv-wifi.com/sdk-js/launch-button.js"); - } - - (window as any).esploader = esploader; + logEl.addRow({ + id: "preparing", + content: "Preparing installation...", + }); const filePromises = build.parts.map(async (part) => { const url = new URL(part.filename, manifestURL).toString(); @@ -95,10 +99,17 @@ export const startFlash = async ( return resp.arrayBuffer(); }); + // Pre-load improv for later + if (build.improv) { + // @ts-ignore + import("https://www.improv-wifi.com/sdk-js/launch-button.js"); + } + // Run the stub while we wait for files to download const espStub = await esploader.runStub(); const files: ArrayBuffer[] = []; + let totalSize = 0; for (const prom of filePromises) { try { @@ -106,14 +117,21 @@ export const startFlash = async ( files.push(data); totalSize += data.byteLength; } catch (err) { - logEl.errorMsg = err.message; + logEl.addError(err.message); await esploader.disconnect(); return; } } - logEl.totalBytes = totalSize; - logEl.extraMsg = ""; + logEl.removeRow("preparing"); + + if (eraseFirst) { + logEl.addRow({ + id: "erase", + content: html`Erasing device`, + }); + } + let lastPct = -1; for (const part of build.parts) { @@ -125,8 +143,10 @@ export const startFlash = async ( return; } lastPct = newPct; - bytesWritten = newBytesWritten; - logEl.bytesWritten = bytesWritten; + logEl.addRow({ + id: "write", + content: html`Writing progress: ${newPct}%`, + }); }, part.offset ); @@ -134,7 +154,38 @@ export const startFlash = async ( await esploader.softReset(); - logEl.bytesWritten = totalSize; + const doImprov = + build.improv && + customElements.get("improv-wifi-launch-button")?.isSupported; + + logEl.addRow({ + id: "write", + content: html`Writing + complete${doImprov + ? "" + : html`, all done!

`}`, + }); await esploader.disconnect(); + + if (!doImprov) { + return; + } + + // Todo: listen for improv events to know when to close dialog + logEl.addRow({ + id: "improv", + action: true, + content: html` + + `, + }); };