mirror of
https://github.com/esphome/esp-web-tools.git
synced 2025-04-19 13:17:20 +00:00
Rewrite log element
This commit is contained in:
parent
3387152181
commit
357b5f9303
27
README.md
27
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
|
||||
<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.
|
||||
|
@ -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>
|
||||
|
@ -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()],
|
||||
};
|
||||
|
@ -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);
|
163
src/flash-log.ts
163
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<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
52
src/install-button.ts
Normal 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);
|
@ -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
|
||||
>
|
||||
`,
|
||||
});
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user