Fire state changed events and add progess bar (#10)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Bram Kragten 2021-06-12 06:12:22 +02:00 committed by GitHub
parent 4ad010ebd7
commit 4cc28e148b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1416 additions and 1019 deletions

View File

@ -47,12 +47,57 @@ All attributes can also be set via properties (`manifest`, `eraseFirst`)
## Styling
### Attributes
The following attributes are automatically added to `<esp-web-install-button>`:
| Attribute | Description |
| -- | -- |
| `install-supported` | Added if installing firmware is supported
| `install-unsupported` | Added if installing firmware is not supported
| `active` | Added when flashing is active
You can add the following attributes or properties to change the UI elements:
| Attribute | Property | Description |
| -- | -- | -- |
| `show-log` | `showLog` | Show a log style view of the progress instead of a progress bar
| `hide-progress` | `hideProgress` | Hides all progress UI elements
### CSS custom properties (variables)
The following variables can be used to change the colors of the default UI elements:
- `--esp-tools-button-color`
- `--esp-tools-button-text-color`
- `--esp-tools-success-color`
- `--esp-tools-error-color`
- `--esp-tools-progress-color`
- `--esp-tools-log-background`
- `--esp-tools-log-text-color`
### Slots
The following slots are available:
| Slot name | Description |
| -- | -- |
| `activate` | Button to start the flash progress
| `unsupported` | Message to show when the browser is not supported
## Events
When the state of flashing changes, a `state-changed` event is fired.
A `state-changed` event contains the following information:
Field | Description
-- | --
state | The current [state](https://github.com/esphome/esp-web-tools/blob/main/src/const.ts)
message | A description of the current state
manifest | The loaded manifest
chipFamily | The chip that was detected;&nbsp;"ESP32" \| "ESP8266" \| "ESP32-S2" \| "Unknown Chip"
details | An optional extra field that is different [per state](https://github.com/esphome/esp-web-tools/blob/main/src/const.ts)
## Development

View File

@ -27,9 +27,6 @@
.project .logo img {
width: 100%;
}
esp-web-flash-log {
margin-top: 8px;
}
a {
color: #03a9f4;
}
@ -61,6 +58,13 @@
font-style: italic;
margin-top: 16px;
}
table {
border-spacing: 0;
}
td {
padding: 8px;
border-bottom: 1px solid #ccc;
}
</style>
<script module>
import(
@ -236,13 +240,58 @@
<h3>Customizing the look and feel</h3>
<p>
You can customize both the activation button and the message that is
shown when the user uses an unsupported browser. This can be done using
the <code>activate</code> and <code>unsupported</code> slots:
There are multiple options to change the look and feel of the button and
other elements.
</p>
<h4>Change colors</h4>
<p>
You can change the colors of the default UI elements with CSS custom
properties (variables), the following variables are available:
</p>
<ul>
<li><code>--esp-tools-button-color</code></li>
<li><code>--esp-tools-button-text-color</code></li>
<li><code>--esp-tools-success-color</code></li>
<li><code>--esp-tools-error-color</code></li>
<li><code>--esp-tools-progress-color</code></li>
<li><code>--esp-tools-log-background</code></li>
<li><code>--esp-tools-log-text-color</code></li>
</ul>
<p>There are also some attributes that can be used for styling:</p>
<table>
<tr>
<td><code>install-supported</code></td>
<td>Added if installing firmware is supported</td>
</tr>
<tr>
<td>
<code>install-unsupported</code>
</td>
<td>Added if installing firmware is not supported</td>
</tr>
<tr>
<td><code>active</code></td>
<td>Added when flashing is active</td>
</tr>
</table>
<p>
When you are using a custom button, you should disable it when the
<code>active</code> attribute is present.
</p>
<h4>Replace the button and message with a custom one</h4>
<p>
You can replace both the activation button and the message that is shown
when the user uses an unsupported browser with your own elements. This
can be done using the <code>activate</code> and
<code>unsupported</code> slots:
</p>
<pre>
&lt;esp-web-install-button
manifest="static/firmware_build/manifest.json"
show-log
erase-first
>
&lt;button slot="activate">Custom install button&lt;/button>
@ -250,6 +299,91 @@
&lt;/esp-web-install-button>
</pre
>
<h4>Show or hide the progress bar and log</h4>
<p>
By default there is a progress bar showing the state and progress of the
flashing progress, you can chnage this progress bar to a log view with
the <code>show-log</code> attribute.
</p>
<p>
You can also hide all progress indicators by adding the `hide-progress`
attribute. This will hide both the progress bar and the log view. You
can then implement your own progress elements using the
<a href="#state-events">state events</a>.
</p>
<h3 id="state-events">State events</h3>
<p>
During the flash progress the button will fire
<code>state-changed</code> events for every step of the progress and to
signal progress in the writing.
</p>
<p>
With these events you can create your own progress UI or trigger certain
actions. You can also find the current state as the
<code>state</code> property of the
<code>esp-web-install-button</code> element.
</p>
<p>Events for the following states are fired:</p>
<ul>
<li>initializing</li>
<li>manifest</li>
<li>preparing</li>
<li>erasing</li>
<li>writing</li>
<li>finished</li>
<li>error</li>
</ul>
<p>
A <code>state-changed</code> event contains the following information:
</p>
<table>
<tr>
<td><code>state</code></td>
<td>The current state; one of the above</td>
</tr>
<tr>
<td><code>message</code></td>
<td>A description of the current state</td>
</tr>
<tr>
<td><code>manifest</code></td>
<td>The loaded manifest</td>
</tr>
<tr>
<td><code>build</code></td>
<td>The manifest's build that was selected</td>
</tr>
<tr>
<td><code>chipFamily</code></td>
<td>
The chip that was detected;
<code>"ESP32" | "ESP8266" | "ESP32-S2" | "Unknown Chip"</code>
</td>
</tr>
<tr>
<td><code>details</code></td>
<td>
An optional extra field that is different
<a
href="https://github.com/esphome/esp-web-tools/blob/main/src/const.ts"
>per state</a
>
</td>
</tr>
</table>
<p>An example that logs all state events:</p>
<pre>
&lt;esp-web-install-button
manifest="static/firmware_build/manifest.json"
>&lt;/esp-web-install-button>
&lt;script>
const espWebInstallButton = document.querySelector("esp-web-install-button");
espWebInstallButton.addEventListener(
"state-changed", (ev) => { console.log(ev.detail) }
);
&lt;/script>
</pre>
<div class="footer">
<div>
ESP Web Tools

1301
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,6 @@
"@rollup/plugin-node-resolve": "^13.0.0",
"@rollup/plugin-typescript": "^8.2.1",
"@types/w3c-web-serial": "^1.0.1",
"@types/web-bluetooth": "^0.0.9",
"prettier": "^2.3.0",
"rollup": "^2.50.2",
"rollup-plugin-terser": "^7.0.2",
@ -22,10 +21,8 @@
"typescript": "^4.3.2"
},
"dependencies": {
"@material/mwc-button": "^0.21.0",
"@material/mwc-circular-progress": "^0.21.0",
"@material/mwc-dialog": "^0.21.0",
"@material/mwc-textfield": "^0.21.0",
"@material/mwc-base": "^0.21.0",
"@material/mwc-linear-progress": "^0.21.0",
"esp-web-flasher": "^1.0.4",
"lit": "^2.0.0-rc.2",
"tslib": "^2.2.0"

View File

@ -11,3 +11,78 @@ export interface Manifest {
name: string;
builds: Build[];
}
interface BaseFlashState {
state: State;
message: string;
manifest?: Manifest;
build?: Build;
chipFamily?: "ESP32" | "ESP8266" | "ESP32-S2" | "Unknown Chip";
}
export interface InitializingState extends BaseFlashState {
state: State.INITIALIZING;
details: { done: boolean };
}
export interface ManifestState extends BaseFlashState {
state: State.MANIFEST;
details: { done: boolean };
}
export interface PreparingState extends BaseFlashState {
state: State.PREPARING;
details: { done: boolean };
}
export interface ErasingState extends BaseFlashState {
state: State.ERASING;
details: { done: boolean };
}
export interface WritingState extends BaseFlashState {
state: State.WRITING;
details: { bytesTotal: number; bytesWritten: number; percentage: number };
}
export interface FinishedState extends BaseFlashState {
state: State.FINISHED;
}
export interface ErrorState extends BaseFlashState {
state: State.ERROR;
details: { error: FlashError; details: string | Error };
}
export type FlashState =
| InitializingState
| ManifestState
| PreparingState
| ErasingState
| WritingState
| FinishedState
| ErrorState;
export const enum State {
INITIALIZING = "initializing",
MANIFEST = "manifest",
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<FlashState>;
}
}

View File

@ -1,17 +1,18 @@
import { css, html, HTMLTemplateResult, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { FlashState, State } from "./const";
interface Row {
id?: string;
content: HTMLTemplateResult | string;
state?: State;
message: HTMLTemplateResult | string;
error?: boolean;
action?: boolean;
}
@customElement("esp-web-flash-log")
class FlashLog extends LitElement {
@state() _rows: Row[] = [];
export class FlashLog extends LitElement {
@state() private _rows: Row[] = [];
protected render() {
return html`${this._rows.map(
@ -22,20 +23,41 @@ class FlashLog extends LitElement {
action: row.action === true,
})}
>
${row.content}
${row.message}
</div>`
)}`;
}
public willUpdate() {
this.toggleAttribute("hidden", !this._rows.length);
}
public clear() {
this._rows = [];
}
public processState(state: FlashState) {
if (state.state === State.ERROR) {
this.addError(state.message);
return;
}
this.addRow(state);
if (state.state === State.FINISHED) {
this.addAction(
html`<button @click=${this.clear}>Close this log</button>`
);
}
}
/**
* Add or replace a row.
*/
public addRow(row: Row) {
// If last entry has same ID, replace it.
if (
row.id &&
row.state &&
this._rows.length > 0 &&
this._rows[this._rows.length - 1].id === row.id
this._rows[this._rows.length - 1].state === row.state
) {
const newRows = this._rows.slice(0, -1);
newRows.push(row);
@ -48,15 +70,25 @@ class FlashLog extends LitElement {
/**
* Add an error row
*/
public addError(content: Row["content"]) {
this.addRow({ content, error: true });
public addError(message: Row["message"]) {
this.addRow({ message, error: true });
}
/**
* Remove last row if ID matches
* Add an action row
*/
public removeRow(id: string) {
if (this._rows.length > 0 && this._rows[this._rows.length - 1].id === id) {
public addAction(message: Row["message"]) {
this.addRow({ message, action: true });
}
/**
* Remove last row if state matches
*/
public removeRow(state: string) {
if (
this._rows.length > 0 &&
this._rows[this._rows.length - 1].state === state
) {
this._rows = this._rows.slice(0, -1);
}
}
@ -64,13 +96,17 @@ class FlashLog extends LitElement {
static styles = css`
:host {
display: block;
max-width: 500px;
margin-top: 16px;
padding: 12px 16px;
font-family: monospace;
background-color: black;
color: greenyellow;
background: var(--esp-tools-log-background, black);
color: var(--esp-tools-log-text-color, greenyellow);
font-size: 14px;
line-height: 19px;
padding: 12px 16px;
}
:host([hidden]) {
display: none;
}
button {
@ -84,13 +120,13 @@ class FlashLog extends LitElement {
cursor: pointer;
}
.action,
.error {
margin-top: 1em;
color: var(--esp-tools-error-color, #dc3545);
}
.error {
color: red;
.error,
.action {
margin-top: 1em;
}
`;
}

85
src/flash-progress.ts Normal file
View File

@ -0,0 +1,85 @@
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { FlashState, State } from "./const";
import "@material/mwc-linear-progress";
import { classMap } from "lit/directives/class-map.js";
@customElement("esp-web-flash-progress")
export class FlashProgress extends LitElement {
@state() private _state?: FlashState;
@state() private _indeterminate = true;
@state() private _progress = 0;
public processState(state: FlashState) {
this._state = state;
if (this._state.state === State.WRITING) {
this._indeterminate = false;
this._progress = this._state.details.percentage / 100;
}
if (this._state.state === State.ERROR) {
this._indeterminate = false;
}
}
public clear() {
this._state = undefined;
this._progress = 0;
this._indeterminate = true;
}
protected render() {
if (!this._state) {
return;
}
return html`<h2
class=${classMap({
error: this._state.state === State.ERROR,
done: this._state.state === State.FINISHED,
})}
>
${this._state.message}
</h2>
<p>
${this._state.manifest
? html`${this._state.manifest.name}: ${this._state.chipFamily}`
: html`&nbsp;`}
</p>
<mwc-linear-progress
class=${classMap({
error: this._state.state === State.ERROR,
done: this._state.state === State.FINISHED,
})}
.indeterminate=${this._indeterminate}
.progress=${this._progress}
></mwc-linear-progress>`;
}
static styles = css`
:host {
display: block;
--mdc-theme-primary: var(--esp-tools-progress-color, #03a9f4);
}
.error {
color: var(--esp-tools-error-color, #dc3545);
--mdc-theme-primary: var(--esp-tools-error-color, #dc3545);
}
.done {
color: var(--esp-tools-success-color, #28a745);
--mdc-theme-primary: var(--esp-tools-success-color, #28a745);
}
h2 {
margin: 16px 0 0;
}
p {
margin: 4px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"esp-web-flash-progress": FlashProgress;
}
}

234
src/flash.ts Normal file
View File

@ -0,0 +1,234 @@
import { connect, ESPLoader, Logger } from "esp-web-flasher";
import { Build, FlashError, FlashState, Manifest, State } from "./const";
import { fireEvent, getChipFamilyName, sleep } from "./util";
export const flash = async (
eventTarget: EventTarget,
logger: Logger,
manifestPath: string,
eraseFirst: boolean
) => {
let manifest: Manifest;
let build: Build | undefined;
let chipFamily: "ESP32" | "ESP8266" | "ESP32-S2" | "Unknown Chip";
const fireStateEvent = (stateUpdate: FlashState) => {
fireEvent(eventTarget, "state-changed", {
...stateUpdate,
manifest,
build,
chipFamily,
});
};
const manifestURL = new URL(manifestPath, location.toString()).toString();
const manifestProm = fetch(manifestURL).then(
(resp): Promise<Manifest> => resp.json()
);
let esploader: ESPLoader | undefined;
try {
esploader = await connect(logger);
} catch (err) {
// User pressed cancel on web serial
return;
}
// For debugging
(window as any).esploader = esploader;
fireStateEvent({
state: State.INITIALIZING,
message: "Initializing...",
details: { done: false },
});
try {
await esploader.initialize();
} catch (err) {
logger.error(err);
if (esploader.connected) {
fireStateEvent({
state: State.ERROR,
message:
"Failed to initialize. Try resetting your device or holding the BOOT button before clicking connect.",
details: { error: FlashError.FAILED_INITIALIZING, details: err },
});
await esploader.disconnect();
}
return;
}
chipFamily = getChipFamilyName(esploader);
fireStateEvent({
state: State.INITIALIZING,
message: `Initialized. Found ${chipFamily}`,
details: { done: true },
});
fireStateEvent({
state: State.MANIFEST,
message: "Fetching manifest...",
details: { done: false },
});
try {
manifest = await manifestProm;
} catch (err) {
fireStateEvent({
state: State.ERROR,
message: `Unable to fetch manifest: ${err.message}`,
details: { error: FlashError.FAILED_MANIFEST_FETCH, details: err },
});
await esploader.disconnect();
return;
}
build = manifest.builds.find((b) => b.chipFamily === chipFamily);
fireStateEvent({
state: State.MANIFEST,
message: `Found manifest for ${manifest.name}`,
details: { done: true },
});
if (!build) {
fireStateEvent({
state: State.ERROR,
message: `Your ${chipFamily} board is not supported.`,
details: { error: FlashError.NOT_SUPPORTED, details: chipFamily },
});
await esploader.disconnect();
return;
}
fireStateEvent({
state: State.PREPARING,
message: "Preparing installation...",
details: { done: false },
});
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(
`Downlading firmware ${part.path} failed: ${resp.status}`
);
}
return resp.arrayBuffer();
});
// 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 {
const data = await prom;
files.push(data);
totalSize += data.byteLength;
} catch (err) {
fireStateEvent({
state: State.ERROR,
message: err,
details: { error: FlashError.FAILED_FIRMWARE_DOWNLOAD, details: err },
});
await esploader.disconnect();
return;
}
}
fireStateEvent({
state: State.PREPARING,
message: "Installation prepared",
details: { done: true },
});
if (eraseFirst) {
fireStateEvent({
state: State.ERASING,
message: "Erasing device...",
details: { done: false },
});
await espStub.eraseFlash();
fireStateEvent({
state: State.ERASING,
message: "Device erased",
details: { done: true },
});
}
let lastPct = 0;
fireStateEvent({
state: State.WRITING,
message: `Writing progress: ${lastPct}%`,
details: {
bytesTotal: totalSize,
bytesWritten: 0,
percentage: lastPct,
},
});
let totalWritten = 0;
for (const part of build.parts) {
const file = files.shift()!;
try {
await espStub.flashData(
file,
(bytesWritten) => {
const newPct = Math.floor(
((totalWritten + bytesWritten) / totalSize) * 100
);
if (newPct === lastPct) {
return;
}
lastPct = newPct;
fireStateEvent({
state: State.WRITING,
message: `Writing progress: ${newPct}%`,
details: {
bytesTotal: totalSize,
bytesWritten: totalWritten + bytesWritten,
percentage: newPct,
},
});
},
part.offset
);
} catch (err) {
fireStateEvent({
state: State.ERROR,
message: err,
details: { error: FlashError.WRITE_FAILED, details: err },
});
await esploader.disconnect();
return;
}
totalWritten += file.byteLength;
}
fireStateEvent({
state: State.WRITING,
message: "Writing complete",
details: {
bytesTotal: totalSize,
bytesWritten: totalWritten,
percentage: 100,
},
});
await sleep(100);
await esploader.softReset();
await esploader.disconnect();
fireStateEvent({
state: State.FINISHED,
message: "All done!",
});
};

View File

@ -1,11 +1,73 @@
class InstallButton extends HTMLElement {
import { FlashState } from "./const";
export class InstallButton extends HTMLElement {
public static isSupported = "serial" in navigator;
private static style = `
button {
position: relative;
cursor: pointer;
font-size: 14px;
padding: 8px 28px;
color: var(--esp-tools-button-text-color, #fff);
background-color: var(--esp-tools-button-color, #03a9f4);
border: none;
border-radius: 4px;
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.12), 0 1px 5px 0 rgba(0,0,0,.2);
}
button::before {
content: " ";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
opacity: 0.2;
border-radius: 4px;
}
button:hover {
box-shadow: 0 4px 8px 0 rgba(0,0,0,.14), 0 1px 7px 0 rgba(0,0,0,.12), 0 3px 1px -1px rgba(0,0,0,.2);
}
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;
}
improv-wifi-launch-button {
display: block;
margin-top: 16px;
}
.hidden {
display: none;
}`;
public manifest?: string;
public eraseFirst?: boolean;
private renderRoot?: ShadowRoot;
public hideProgress?: boolean;
public showLog?: boolean;
public logConsole?: boolean;
public state?: FlashState;
public renderRoot?: ShadowRoot;
public static preload() {
import("./start-flash");
@ -19,34 +81,46 @@ class InstallButton extends HTMLElement {
this.renderRoot = this.attachShadow({ mode: "open" });
if (!InstallButton.isSupported) {
this.setAttribute("install-unsupported", "");
this.renderRoot.innerHTML =
"<slot name='unsupported'>Your browser does not support installing things on ESP devices. Use Google Chrome or Microsoft Edge.</slot>";
this.toggleAttribute("install-unsupported", true);
const slot = document.createElement("slot");
slot.name = "unsupported";
slot.innerText =
"Your browser does not support installing things on ESP devices. Use Google Chrome or Microsoft Edge.";
this.renderRoot.append(slot);
return;
}
this.setAttribute("install-supported", "");
this.toggleAttribute("install-supported", true);
this.addEventListener("mouseover", InstallButton.preload);
this.addEventListener("click", async (ev) => {
ev.preventDefault();
const manifest = this.manifest || this.getAttribute("manifest");
if (!manifest) {
alert("No manifest defined!");
return;
}
const slot = document.createElement("slot");
slot.addEventListener("click", async (ev) => {
ev.preventDefault();
const mod = await import("./start-flash");
await mod.startFlash(
console,
manifest,
(logEl) => this.parentElement!.insertBefore(logEl, this.nextSibling),
this.eraseFirst !== undefined
? this.eraseFirst
: this.hasAttribute("erase-first")
);
mod.startFlash(this);
});
this.renderRoot.innerHTML = `<slot name='activate'><button>Install</button></slot>`;
slot.name = "activate";
const button = document.createElement("button");
button.innerText = "INSTALL";
slot.append(button);
if (
"adoptedStyleSheets" in Document.prototype &&
"replaceSync" in CSSStyleSheet.prototype
) {
const sheet = new CSSStyleSheet();
// @ts-expect-error
sheet.replaceSync(InstallButton.style);
// @ts-expect-error
this.renderRoot.adoptedStyleSheets = [sheet];
} else {
const styleSheet = document.createElement("style");
styleSheet.innerText = InstallButton.style;
this.renderRoot.append(styleSheet);
}
this.renderRoot.append(slot);
}
}

View File

@ -1,213 +1,126 @@
import { html } from "lit";
import { connect, ESPLoader, Logger } from "esp-web-flasher";
import { Build, Manifest } from "./const";
import { flash } from "./flash";
import "./flash-log";
import { getChipFamilyName, sleep } from "./util";
import "./flash-progress";
import type { FlashLog } from "./flash-log";
import type { FlashProgress } from "./flash-progress";
import type { InstallButton } from "./install-button";
import { State } from "./const";
export const startFlash = async (
logger: Logger,
manifestPath: string,
addLogElement: (el: HTMLElement) => void,
eraseFirst: boolean
) => {
const manifestURL = new URL(manifestPath, location.toString()).toString();
const manifestProm = fetch(manifestURL).then(
(resp): Promise<Manifest> => resp.json()
);
let stateListenerAdded = false;
let esploader: ESPLoader | undefined;
let logEl: FlashLog | undefined;
try {
esploader = await connect(logger);
} catch (err) {
// User pressed cancel on web serial
return;
}
let progressEl: FlashProgress | undefined;
// For debugging
(window as any).esploader = esploader;
let improvEl: HTMLElement | undefined;
const logEl = document.createElement("esp-web-flash-log");
// logEl.esploader = esploader;
logEl.addRow({ id: "initializing", content: "Initializing..." });
addLogElement(logEl);
try {
await esploader.initialize();
} catch (err) {
console.error(err);
if (esploader.connected) {
logEl.addError(
"Failed to initialize. Try resetting your device or holding the BOOT button before clicking connect."
);
await esploader.disconnect();
}
return;
}
const chipFamily = getChipFamilyName(esploader);
logEl.addRow({
id: "initializing",
content: html`Initialized. Found ${chipFamily}`,
});
logEl.addRow({ id: "manifest", content: "Fetching manifest..." });
let manifest: Manifest | undefined;
try {
manifest = await manifestProm;
} catch (err) {
logEl.addError(`Unable to fetch manifest: ${err}`);
await esploader.disconnect();
return;
}
logEl.addRow({
id: "manifest",
content: html`Found manifest for ${manifest.name}`,
});
let build: Build | undefined;
for (const b of manifest.builds) {
if (b.chipFamily === chipFamily) {
build = b;
break;
}
}
if (!build) {
logEl.addError(`Your ${chipFamily} board is not supported.`);
await esploader.disconnect();
return;
}
logEl.addRow({
id: "preparing",
content: "Preparing installation...",
});
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(
`Downlading firmware ${part.path} failed: ${resp.status}`
);
}
return resp.arrayBuffer();
});
// 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 {
const data = await prom;
files.push(data);
totalSize += data.byteLength;
} catch (err) {
logEl.addError(err.message);
await esploader.disconnect();
return;
}
}
logEl.addRow({
id: "preparing",
content: `Installation prepared`,
});
// Pre-load improv for later
if (build.improv) {
// @ts-ignore
import("https://www.improv-wifi.com/sdk-js/launch-button.js");
}
if (eraseFirst) {
logEl.addRow({
id: "erase",
content: html`Erasing device...`,
});
await espStub.eraseFlash();
logEl.addRow({
id: "erase",
content: html`Device erased`,
});
}
let lastPct = 0;
logEl.addRow({
id: "write",
content: html`Writing progress: ${lastPct}%`,
});
let totalWritten = 0;
for (const part of build.parts) {
const file = files.shift()!;
await espStub.flashData(
file,
(bytesWritten) => {
const newPct = Math.floor(
((totalWritten + bytesWritten) / totalSize) * 100
);
if (newPct === lastPct) {
return;
}
lastPct = newPct;
logEl.addRow({
id: "write",
content: html`Writing progress: ${newPct}%`,
});
},
part.offset
);
totalWritten += file.byteLength;
}
logEl.addRow({
id: "write",
content: html`Writing progress: 100%`,
});
await sleep(100);
await esploader.softReset();
await esploader.disconnect();
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=${() => logEl.parentElement?.removeChild(logEl)}
>
Close this dialog
</button>`}`,
});
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
>
`,
});
const addElement = <T extends HTMLElement>(
button: InstallButton,
element: T
): T => {
button.renderRoot!.append(element);
return element;
};
export const startFlash = async (button: InstallButton) => {
if (button.hasAttribute("active")) {
return;
}
const manifest = button.manifest || button.getAttribute("manifest");
if (!manifest) {
alert("No manifest defined!");
return;
}
let hasImprov = false;
if (!stateListenerAdded) {
stateListenerAdded = true;
button.addEventListener("state-changed", (ev) => {
const state = (button.state = ev.detail);
if (state.state === State.INITIALIZING) {
button.toggleAttribute("active", true);
} else if (state.state === State.MANIFEST && state.build?.improv) {
hasImprov = true;
// @ts-ignore
// preload improv button
import("https://www.improv-wifi.com/sdk-js/launch-button.js");
} else if (state.state === State.FINISHED) {
button.toggleAttribute("active", false);
if (hasImprov) {
startImprov(button);
}
} else if (state.state === State.ERROR) {
button.toggleAttribute("active", false);
}
progressEl?.processState(ev.detail);
logEl?.processState(ev.detail);
});
}
const logConsole = button.logConsole || button.hasAttribute("log-console");
const showLog = button.showLog || button.hasAttribute("show-log");
const showProgress =
!showLog &&
button.hideProgress !== true &&
!button.hasAttribute("hide-progress");
if (showLog && !logEl) {
logEl = addElement<FlashLog>(
button,
document.createElement("esp-web-flash-log")
);
} else if (!showLog && logEl) {
logEl.remove();
logEl = undefined;
}
if (showProgress && !progressEl) {
progressEl = addElement<FlashProgress>(
button,
document.createElement("esp-web-flash-progress")
);
} else if (!showProgress && progressEl) {
progressEl.remove();
progressEl = undefined;
}
logEl?.clear();
progressEl?.clear();
improvEl?.classList.toggle("hidden", true);
flash(
button,
logConsole
? console
: {
log: () => {},
error: () => {},
debug: () => {},
},
manifest,
button.eraseFirst !== undefined
? button.eraseFirst
: button.hasAttribute("erase-first")
);
};
const startImprov = async (button: InstallButton) => {
// @ts-ignore
await import("https://www.improv-wifi.com/sdk-js/launch-button.js");
if (!customElements.get("improv-wifi-launch-button").isSupported) {
return;
}
if (!improvEl) {
improvEl = document.createElement("improv-wifi-launch-button");
const improvButton = document.createElement("button");
improvButton.slot = "activate";
improvButton.textContent = "CLICK HERE TO FINISH SETTING UP YOUR DEVICE";
improvEl.appendChild(improvButton);
addElement(button, improvEl);
}
improvEl.classList.toggle("hidden", false);
};

View File

@ -20,3 +20,24 @@ export const getChipFamilyName = (esploader: ESPLoader) => {
export const sleep = (time: number) =>
new Promise((resolve) => setTimeout(resolve, time));
export const fireEvent = <Event extends keyof HTMLElementEventMap>(
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);
};