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
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
{
@ -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
<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
Run `script/develop`. This starts a server. Open it on http://localhost:5000.

View File

@ -17,13 +17,13 @@
<body>
<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>
<esphome-web-flash-button
<esphome-web-install-button
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>
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>
<script src="./dist/web/flash-button.js" type="module"></script>
<script src="./dist/web/install-button.js" type="module"></script>
</body>
</html>

View File

@ -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()],
};

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 { 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<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
protected render() {
return html`${this._rows.map(
(row) =>
html`<div
class=${classMap({
error: row.error === true,
action: row.action === true,
})}
>
`);
}
return this._renderBody(lines, !doImprov);
${row.content}
</div>`
)}`;
}
private _renderBody(
lines: Array<HTMLTemplateResult | string>,
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` <br /><button @click=${this._close}>Close this dialog</button> `
: ""}
${this.errorMsg
? html`<div class="error">Error: ${this.errorMsg}</div>`
: ""}
${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());
/**
* 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;
}
`;

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 { 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<Manifest> => 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!<br /><br /><button
@click=${() => logParent.removeChild(logEl)}
>
Close this dialog
</button>`}`,
});
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
>
`,
});
};