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 ## Styling
### Attributes
The following attributes are automatically added to `<esp-web-install-button>`: The following attributes are automatically added to `<esp-web-install-button>`:
| Attribute | Description | | Attribute | Description |
| -- | -- | | -- | -- |
| `install-supported` | Added if installing firmware is supported | `install-supported` | Added if installing firmware is supported
| `install-unsupported` | Added if installing firmware is not 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 ## Development

View File

@ -27,9 +27,6 @@
.project .logo img { .project .logo img {
width: 100%; width: 100%;
} }
esp-web-flash-log {
margin-top: 8px;
}
a { a {
color: #03a9f4; color: #03a9f4;
} }
@ -61,6 +58,13 @@
font-style: italic; font-style: italic;
margin-top: 16px; margin-top: 16px;
} }
table {
border-spacing: 0;
}
td {
padding: 8px;
border-bottom: 1px solid #ccc;
}
</style> </style>
<script module> <script module>
import( import(
@ -236,13 +240,58 @@
<h3>Customizing the look and feel</h3> <h3>Customizing the look and feel</h3>
<p> <p>
You can customize both the activation button and the message that is There are multiple options to change the look and feel of the button and
shown when the user uses an unsupported browser. This can be done using other elements.
the <code>activate</code> and <code>unsupported</code> slots: </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> </p>
<pre> <pre>
&lt;esp-web-install-button &lt;esp-web-install-button
manifest="static/firmware_build/manifest.json" manifest="static/firmware_build/manifest.json"
show-log
erase-first erase-first
> >
&lt;button slot="activate">Custom install button&lt;/button> &lt;button slot="activate">Custom install button&lt;/button>
@ -250,6 +299,91 @@
&lt;/esp-web-install-button> &lt;/esp-web-install-button>
</pre </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 class="footer">
<div> <div>
ESP Web Tools 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-node-resolve": "^13.0.0",
"@rollup/plugin-typescript": "^8.2.1", "@rollup/plugin-typescript": "^8.2.1",
"@types/w3c-web-serial": "^1.0.1", "@types/w3c-web-serial": "^1.0.1",
"@types/web-bluetooth": "^0.0.9",
"prettier": "^2.3.0", "prettier": "^2.3.0",
"rollup": "^2.50.2", "rollup": "^2.50.2",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
@ -22,10 +21,8 @@
"typescript": "^4.3.2" "typescript": "^4.3.2"
}, },
"dependencies": { "dependencies": {
"@material/mwc-button": "^0.21.0", "@material/mwc-base": "^0.21.0",
"@material/mwc-circular-progress": "^0.21.0", "@material/mwc-linear-progress": "^0.21.0",
"@material/mwc-dialog": "^0.21.0",
"@material/mwc-textfield": "^0.21.0",
"esp-web-flasher": "^1.0.4", "esp-web-flasher": "^1.0.4",
"lit": "^2.0.0-rc.2", "lit": "^2.0.0-rc.2",
"tslib": "^2.2.0" "tslib": "^2.2.0"

View File

@ -11,3 +11,78 @@ export interface Manifest {
name: string; name: string;
builds: Build[]; 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 { css, html, HTMLTemplateResult, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js"; import { classMap } from "lit/directives/class-map.js";
import { FlashState, State } from "./const";
interface Row { interface Row {
id?: string; state?: State;
content: HTMLTemplateResult | string; message: HTMLTemplateResult | string;
error?: boolean; error?: boolean;
action?: boolean; action?: boolean;
} }
@customElement("esp-web-flash-log") @customElement("esp-web-flash-log")
class FlashLog extends LitElement { export class FlashLog extends LitElement {
@state() _rows: Row[] = []; @state() private _rows: Row[] = [];
protected render() { protected render() {
return html`${this._rows.map( return html`${this._rows.map(
@ -22,20 +23,41 @@ class FlashLog extends LitElement {
action: row.action === true, action: row.action === true,
})} })}
> >
${row.content} ${row.message}
</div>` </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. * Add or replace a row.
*/ */
public addRow(row: Row) { public addRow(row: Row) {
// If last entry has same ID, replace it. // If last entry has same ID, replace it.
if ( if (
row.id && row.state &&
this._rows.length > 0 && 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); const newRows = this._rows.slice(0, -1);
newRows.push(row); newRows.push(row);
@ -48,15 +70,25 @@ class FlashLog extends LitElement {
/** /**
* Add an error row * Add an error row
*/ */
public addError(content: Row["content"]) { public addError(message: Row["message"]) {
this.addRow({ content, error: true }); this.addRow({ message, error: true });
} }
/** /**
* Remove last row if ID matches * Add an action row
*/ */
public removeRow(id: string) { public addAction(message: Row["message"]) {
if (this._rows.length > 0 && this._rows[this._rows.length - 1].id === id) { 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); this._rows = this._rows.slice(0, -1);
} }
} }
@ -64,13 +96,17 @@ class FlashLog extends LitElement {
static styles = css` static styles = css`
:host { :host {
display: block; display: block;
max-width: 500px; margin-top: 16px;
padding: 12px 16px;
font-family: monospace; font-family: monospace;
background-color: black; background: var(--esp-tools-log-background, black);
color: greenyellow; color: var(--esp-tools-log-text-color, greenyellow);
font-size: 14px; font-size: 14px;
line-height: 19px; line-height: 19px;
padding: 12px 16px; }
:host([hidden]) {
display: none;
} }
button { button {
@ -84,13 +120,13 @@ class FlashLog extends LitElement {
cursor: pointer; cursor: pointer;
} }
.action,
.error { .error {
margin-top: 1em; color: var(--esp-tools-error-color, #dc3545);
} }
.error { .error,
color: red; .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; 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 manifest?: string;
public eraseFirst?: boolean; public eraseFirst?: boolean;
private renderRoot?: ShadowRoot; public hideProgress?: boolean;
public showLog?: boolean;
public logConsole?: boolean;
public state?: FlashState;
public renderRoot?: ShadowRoot;
public static preload() { public static preload() {
import("./start-flash"); import("./start-flash");
@ -19,34 +81,46 @@ class InstallButton extends HTMLElement {
this.renderRoot = this.attachShadow({ mode: "open" }); this.renderRoot = this.attachShadow({ mode: "open" });
if (!InstallButton.isSupported) { if (!InstallButton.isSupported) {
this.setAttribute("install-unsupported", ""); this.toggleAttribute("install-unsupported", true);
this.renderRoot.innerHTML = const slot = document.createElement("slot");
"<slot name='unsupported'>Your browser does not support installing things on ESP devices. Use Google Chrome or Microsoft Edge.</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; return;
} }
this.setAttribute("install-supported", ""); this.toggleAttribute("install-supported", true);
this.addEventListener("mouseover", InstallButton.preload); 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"); const mod = await import("./start-flash");
await mod.startFlash( mod.startFlash(this);
console,
manifest,
(logEl) => this.parentElement!.insertBefore(logEl, this.nextSibling),
this.eraseFirst !== undefined
? this.eraseFirst
: this.hasAttribute("erase-first")
);
}); });
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 { flash } from "./flash";
import { connect, ESPLoader, Logger } from "esp-web-flasher";
import { Build, Manifest } from "./const";
import "./flash-log"; 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 ( let stateListenerAdded = false;
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 esploader: ESPLoader | undefined; let logEl: FlashLog | undefined;
try { let progressEl: FlashProgress | undefined;
esploader = await connect(logger);
} catch (err) {
// User pressed cancel on web serial
return;
}
// For debugging let improvEl: HTMLElement | undefined;
(window as any).esploader = esploader;
const logEl = document.createElement("esp-web-flash-log"); const addElement = <T extends HTMLElement>(
// logEl.esploader = esploader; button: InstallButton,
logEl.addRow({ id: "initializing", content: "Initializing..." }); element: T
addLogElement(logEl); ): T => {
button.renderRoot!.append(element);
try { return element;
await esploader.initialize(); };
} catch (err) {
console.error(err); export const startFlash = async (button: InstallButton) => {
if (esploader.connected) { if (button.hasAttribute("active")) {
logEl.addError( return;
"Failed to initialize. Try resetting your device or holding the BOOT button before clicking connect." }
);
await esploader.disconnect(); const manifest = button.manifest || button.getAttribute("manifest");
} if (!manifest) {
return; alert("No manifest defined!");
} return;
}
const chipFamily = getChipFamilyName(esploader);
let hasImprov = false;
logEl.addRow({
id: "initializing", if (!stateListenerAdded) {
content: html`Initialized. Found ${chipFamily}`, stateListenerAdded = true;
}); button.addEventListener("state-changed", (ev) => {
logEl.addRow({ id: "manifest", content: "Fetching manifest..." }); const state = (button.state = ev.detail);
if (state.state === State.INITIALIZING) {
let manifest: Manifest | undefined; button.toggleAttribute("active", true);
try { } else if (state.state === State.MANIFEST && state.build?.improv) {
manifest = await manifestProm; hasImprov = true;
} catch (err) { // @ts-ignore
logEl.addError(`Unable to fetch manifest: ${err}`); // preload improv button
await esploader.disconnect(); import("https://www.improv-wifi.com/sdk-js/launch-button.js");
return; } else if (state.state === State.FINISHED) {
} button.toggleAttribute("active", false);
if (hasImprov) {
logEl.addRow({ startImprov(button);
id: "manifest", }
content: html`Found manifest for ${manifest.name}`, } else if (state.state === State.ERROR) {
}); button.toggleAttribute("active", false);
}
let build: Build | undefined; progressEl?.processState(ev.detail);
for (const b of manifest.builds) { logEl?.processState(ev.detail);
if (b.chipFamily === chipFamily) { });
build = b; }
break;
} const logConsole = button.logConsole || button.hasAttribute("log-console");
} const showLog = button.showLog || button.hasAttribute("show-log");
const showProgress =
if (!build) { !showLog &&
logEl.addError(`Your ${chipFamily} board is not supported.`); button.hideProgress !== true &&
await esploader.disconnect(); !button.hasAttribute("hide-progress");
return;
} if (showLog && !logEl) {
logEl = addElement<FlashLog>(
logEl.addRow({ button,
id: "preparing", document.createElement("esp-web-flash-log")
content: "Preparing installation...", );
}); } else if (!showLog && logEl) {
logEl.remove();
const filePromises = build.parts.map(async (part) => { logEl = undefined;
const url = new URL(part.path, manifestURL).toString(); }
const resp = await fetch(url);
if (!resp.ok) { if (showProgress && !progressEl) {
throw new Error( progressEl = addElement<FlashProgress>(
`Downlading firmware ${part.path} failed: ${resp.status}` button,
); document.createElement("esp-web-flash-progress")
} );
return resp.arrayBuffer(); } else if (!showProgress && progressEl) {
}); progressEl.remove();
progressEl = undefined;
// Run the stub while we wait for files to download }
const espStub = await esploader.runStub();
logEl?.clear();
const files: ArrayBuffer[] = []; progressEl?.clear();
let totalSize = 0; improvEl?.classList.toggle("hidden", true);
for (const prom of filePromises) { flash(
try { button,
const data = await prom; logConsole
files.push(data); ? console
totalSize += data.byteLength; : {
} catch (err) { log: () => {},
logEl.addError(err.message); error: () => {},
await esploader.disconnect(); debug: () => {},
return; },
} manifest,
} button.eraseFirst !== undefined
? button.eraseFirst
logEl.addRow({ : button.hasAttribute("erase-first")
id: "preparing", );
content: `Installation prepared`, };
});
const startImprov = async (button: InstallButton) => {
// Pre-load improv for later // @ts-ignore
if (build.improv) { await import("https://www.improv-wifi.com/sdk-js/launch-button.js");
// @ts-ignore
import("https://www.improv-wifi.com/sdk-js/launch-button.js"); if (!customElements.get("improv-wifi-launch-button").isSupported) {
} return;
}
if (eraseFirst) {
logEl.addRow({ if (!improvEl) {
id: "erase", improvEl = document.createElement("improv-wifi-launch-button");
content: html`Erasing device...`, const improvButton = document.createElement("button");
}); improvButton.slot = "activate";
await espStub.eraseFlash(); improvButton.textContent = "CLICK HERE TO FINISH SETTING UP YOUR DEVICE";
logEl.addRow({ improvEl.appendChild(improvButton);
id: "erase", addElement(button, improvEl);
content: html`Device erased`, }
}); improvEl.classList.toggle("hidden", false);
}
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
>
`,
});
}; };

View File

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