Rewrite log element

This commit is contained in:
Paulus Schoutsen 2021-05-30 22:27:47 -07:00
parent 3387152181
commit 357b5f9303
7 changed files with 220 additions and 189 deletions

View File

@ -1,8 +1,14 @@
# JavaScript SDK for ESPHome # 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
<esphome-web-install-button
manifest="firmware_esphome/manifest.json"
></esphome-web-install-button>
```
Manifest definition:
```json ```json
{ {
@ -17,11 +23,28 @@ Defined using a manifest.
{ "filename": "ota.bin", "offset": 57344 }, { "filename": "ota.bin", "offset": 57344 },
{ "filename": "firmware.bin", "offset": 65536 } { "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
<esphome-web-install-button
manifest="firmware_esphome/manifest.json"
erase-first
></esphome-web-install-button>
```
All attributes can also be set via properties (`manifest`, `eraseFirst`)
## Development ## Development
Run `script/develop`. This starts a server. Open it on http://localhost:5000. Run `script/develop`. This starts a server. Open it on http://localhost:5000.

View File

@ -17,13 +17,13 @@
<body> <body>
<p>ESPHome Web is a set of tools to allow working with ESP devices in the browser.</p> <p>ESPHome Web is a set of tools to allow working with ESP devices in the browser.</p>
<p>To flash the XX firmware, connect an ESP to your computer and hit the button:</p> <p>To flash the XX firmware, connect an ESP to your computer and hit the button:</p>
<esphome-web-flash-button <esphome-web-install-button
manifest="firmware_build/manifest.json" manifest="firmware_build/manifest.json"
></esphome-web-flash-button> ></esphome-web-install-button>
<p><i>Note, this only works in desktop Chrome and Edge. Android support has not been implemented yet.</i></div> <p><i>Note, this only works in desktop Chrome and Edge. Android support has not been implemented yet.</i></div>
<p> <p>
This works by combining Web Serial with a <a href="firmware_build/manifest.json">manifest</a> which describes the firmware. It will automatically detect the type of the connected ESP device and find the right firmware files in the manifest. This works by combining Web Serial with a <a href="firmware_build/manifest.json">manifest</a> which describes the firmware. It will automatically detect the type of the connected ESP device and find the right firmware files in the manifest.
</p> </p>
<script src="./dist/web/flash-button.js" type="module"></script> <script src="./dist/web/install-button.js" type="module"></script>
</body> </body>
</html> </html>

View File

@ -3,11 +3,12 @@ import json from "@rollup/plugin-json";
import { terser } from "rollup-plugin-terser"; import { terser } from "rollup-plugin-terser";
const config = { const config = {
input: "dist/flash-button.js", input: "dist/install-button.js",
output: { output: {
dir: "dist/web", dir: "dist/web",
format: "module", format: "module",
}, },
external: ["https://www.improv-wifi.com/sdk-js/launch-button.js"],
preserveEntrySignatures: false, preserveEntrySignatures: false,
plugins: [nodeResolve(), json()], plugins: [nodeResolve(), json()],
}; };

View File

@ -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
? "<slot name='activate'><button>Flash device</button></slot>"
: "<slot name='unsupported'>Your browser does not support flashing ESP devices. Use Google Chrome or Microsoft Edge.</slot>";
}
}
customElements.define("esphome-web-flash-button", FlashButton);

View File

@ -1,121 +1,64 @@
import { css, html, HTMLTemplateResult, LitElement, PropertyValues } from "lit"; import { css, html, HTMLTemplateResult, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
import { Manifest } from "./const"; import { classMap } from "lit/directives/class-map.js";
import { getChipFamilyName } from "./util";
import { ESPLoader } from "./vendor/esptool/esp_loader"; interface Row {
id?: string;
content: HTMLTemplateResult | string;
error?: boolean;
action?: boolean;
}
@customElement("esphome-web-flash-log") @customElement("esphome-web-flash-log")
class FlashLog extends LitElement { class FlashLog extends LitElement {
@property() public offerImprov = false; @state() _rows: Row[] = [];
@property() public esploader?: ESPLoader; protected render() {
return html`${this._rows.map(
@property() public manifest?: Manifest; (row) =>
html`<div
@property() public totalBytes?: number; class=${classMap({
error: row.error === true,
@property() public bytesWritten?: number; action: row.action === true,
})}
@property() public extraMsg: string = "";
@property() public errorMsg: string = "";
@property() public allowClose = false;
render() {
if (!this.esploader) {
return this._renderBody(["Establishing connection..."]);
}
const lines: Array<HTMLTemplateResult | string> = [
html`Connection established<br />`,
];
if (!this.esploader.chipFamily) {
lines.push("Initializing...");
return this._renderBody(lines);
}
lines.push(
html`Initialized. Found ${getChipFamilyName(this.esploader)}<br />`
);
if (this.manifest === undefined) {
lines.push(html`Fetching manifest...<br />`);
return this._renderBody(lines);
}
lines.push(html`Found manifest for ${this.manifest.name}<br />`);
if (!this.totalBytes) {
return this._renderBody(lines);
}
lines.push(html`Bytes to be written: ${this.totalBytes}<br />`);
if (!this.bytesWritten) {
return this._renderBody(lines);
}
if (this.bytesWritten !== this.totalBytes) {
lines.push(
html`Writing progress:
${Math.floor((this.bytesWritten / this.totalBytes) * 100)}%<br />`
);
return this._renderBody(lines);
}
const doImprov =
this.offerImprov &&
customElements.get("improv-wifi-launch-button")?.isSupported;
lines.push(html`Writing complete${doImprov ? "" : ", all done!"}<br />`);
if (doImprov) {
lines.push(html`
<br />
<improv-wifi-launch-button
><button slot="activate">
Click here to finish setting up your device.
</button></improv-wifi-launch-button
> >
`); ${row.content}
} </div>`
)}`;
return this._renderBody(lines, !doImprov);
} }
private _renderBody( /**
lines: Array<HTMLTemplateResult | string>, * Add or replace a row.
allowClose = false */
) { public addRow(row: Row) {
// allow closing if esploader not connected // If last entry has same ID, replace it.
// or we are at the end. if (
// TODO force allow close if not connected row.id &&
return html` this._rows.length > 0 &&
${lines} ${this.extraMsg} this._rows[this._rows.length - 1].id === row.id
${allowClose ) {
? html` <br /><button @click=${this._close}>Close this dialog</button> ` const newRows = this._rows.slice(0, -1);
: ""} newRows.push(row);
${this.errorMsg this._rows = newRows;
? html`<div class="error">Error: ${this.errorMsg}</div>` } else {
: ""} this._rows = [...this._rows, row];
${this.esploader && !this.esploader.connected
? html`<div class="error">Connection lost</div>`
: ""}
`;
}
protected updated(props: PropertyValues) {
super.updated(props);
if (props.has("esploader") && this.esploader) {
this.esploader.addEventListener("disconnect", () => this.requestUpdate());
} }
} }
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` static styles = css`
@ -141,8 +84,12 @@ class FlashLog extends LitElement {
cursor: pointer; cursor: pointer;
} }
.action,
.error { .error {
margin-top: 1em; margin-top: 1em;
}
.error {
color: red; color: red;
} }
`; `;

52
src/install-button.ts Normal file
View File

@ -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 =
"<slot name='unsupported'>Your browser does not support installing things on ESP devices. Use Google Chrome or Microsoft Edge.</slot>";
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 = `<slot name='activate'><button>Install</button></slot>`;
}
}
customElements.define("esphome-web-install-button", InstallButton);

View File

@ -1,25 +1,23 @@
import { Build, Manifest } from "./const"; import { html } from "lit";
import { connect } from "./vendor/esptool"; import { connect } from "./vendor/esptool";
import { Logger } from "./vendor/esptool/const"; import type { Logger } from "./vendor/esptool/const";
import { ESPLoader } from "./vendor/esptool/esp_loader"; import type { ESPLoader } from "./vendor/esptool/esp_loader";
import { Build, Manifest } from "./const";
import "./flash-log"; import "./flash-log";
import { getChipFamilyName } from "./util"; import { getChipFamilyName } from "./util";
export const startFlash = async ( export const startFlash = async (
logger: Logger, logger: Logger,
manifestPath: string, manifestPath: string,
logParent: HTMLElement logParent: HTMLElement,
eraseFirst: boolean
) => { ) => {
const manifestURL = new URL(manifestPath, location.toString()).toString(); const manifestURL = new URL(manifestPath, location.toString()).toString();
const manifestProm = fetch(manifestURL).then( const manifestProm = fetch(manifestURL).then(
(resp): Promise<Manifest> => resp.json() (resp): Promise<Manifest> => resp.json()
); );
let bytesWritten = 0;
let totalSize = 0;
let esploader: ESPLoader | undefined; let esploader: ESPLoader | undefined;
let manifest: Manifest | undefined;
try { try {
esploader = await connect(logger); esploader = await connect(logger);
@ -28,8 +26,12 @@ export const startFlash = async (
return; return;
} }
// For debugging
(window as any).esploader = esploader;
const logEl = document.createElement("esphome-web-flash-log"); const logEl = document.createElement("esphome-web-flash-log");
logEl.esploader = esploader; // logEl.esploader = esploader;
logEl.addRow({ id: "initializing", content: "Initializing..." });
logParent.append(logEl); logParent.append(logEl);
try { try {
@ -37,25 +39,33 @@ export const startFlash = async (
} catch (err) { } catch (err) {
console.error(err); console.error(err);
if (esploader.connected) { if (esploader.connected) {
logEl.errorMsg = logEl.addError(
"Failed to initialize. Try resetting your device or holding the BOOT button before clicking connect."; "Failed to initialize. Try resetting your device or holding the BOOT button before clicking connect."
);
await esploader.disconnect(); await esploader.disconnect();
} }
return; return;
} }
// To reflect initialized status logEl.addRow({
logEl.requestUpdate(); id: "initializing",
content: html`Initialized. Found ${getChipFamilyName(esploader)}`,
});
logEl.addRow({ id: "manifest", content: "Fetching manifest..." });
let manifest: Manifest | undefined;
try { try {
manifest = await manifestProm; manifest = await manifestProm;
} catch (err) { } catch (err) {
logEl.errorMsg = `Unable to fetch manifest: ${err}`; logEl.addError(`Unable to fetch manifest: ${err}`);
await esploader.disconnect(); await esploader.disconnect();
return; return;
} }
logEl.manifest = manifest; logEl.addRow({
id: "manifest",
content: html`Found manifest for ${manifest.name}`,
});
const chipFamily = getChipFamilyName(esploader); const chipFamily = getChipFamilyName(esploader);
@ -68,21 +78,15 @@ export const startFlash = async (
} }
if (!build) { if (!build) {
logEl.errorMsg = `Your ${chipFamily} board is not supported.`; logEl.addError(`Your ${chipFamily} board is not supported.`);
await esploader.disconnect(); await esploader.disconnect();
return; return;
} }
logEl.offerImprov = build.improv; logEl.addRow({
logEl.extraMsg = "Preparing installation..."; id: "preparing",
content: "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;
const filePromises = build.parts.map(async (part) => { const filePromises = build.parts.map(async (part) => {
const url = new URL(part.filename, manifestURL).toString(); const url = new URL(part.filename, manifestURL).toString();
@ -95,10 +99,17 @@ export const startFlash = async (
return resp.arrayBuffer(); 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 // Run the stub while we wait for files to download
const espStub = await esploader.runStub(); const espStub = await esploader.runStub();
const files: ArrayBuffer[] = []; const files: ArrayBuffer[] = [];
let totalSize = 0;
for (const prom of filePromises) { for (const prom of filePromises) {
try { try {
@ -106,14 +117,21 @@ export const startFlash = async (
files.push(data); files.push(data);
totalSize += data.byteLength; totalSize += data.byteLength;
} catch (err) { } catch (err) {
logEl.errorMsg = err.message; logEl.addError(err.message);
await esploader.disconnect(); await esploader.disconnect();
return; return;
} }
} }
logEl.totalBytes = totalSize; logEl.removeRow("preparing");
logEl.extraMsg = "";
if (eraseFirst) {
logEl.addRow({
id: "erase",
content: html`Erasing device`,
});
}
let lastPct = -1; let lastPct = -1;
for (const part of build.parts) { for (const part of build.parts) {
@ -125,8 +143,10 @@ export const startFlash = async (
return; return;
} }
lastPct = newPct; lastPct = newPct;
bytesWritten = newBytesWritten; logEl.addRow({
logEl.bytesWritten = bytesWritten; id: "write",
content: html`Writing progress: ${newPct}%`,
});
}, },
part.offset part.offset
); );
@ -134,7 +154,38 @@ export const startFlash = async (
await esploader.softReset(); 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!<br /><br /><button
@click=${() => logParent.removeChild(logEl)}
>
Close this dialog
</button>`}`,
});
await esploader.disconnect(); 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`
<improv-wifi-launch-button
><button slot="activate">
Click here to finish setting up your device.
</button></improv-wifi-launch-button
>
`,
});
}; };