mirror of
https://github.com/esphome/esp-web-tools.git
synced 2025-07-28 06:06:36 +00:00
Fire state changed events and add progess bar (#10)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
parent
4ad010ebd7
commit
4cc28e148b
45
README.md
45
README.md
@ -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; "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
|
||||
|
||||
|
146
index.html
146
index.html
@ -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>
|
||||
<esp-web-install-button
|
||||
manifest="static/firmware_build/manifest.json"
|
||||
show-log
|
||||
erase-first
|
||||
>
|
||||
<button slot="activate">Custom install button</button>
|
||||
@ -250,6 +299,91 @@
|
||||
</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>
|
||||
<esp-web-install-button
|
||||
manifest="static/firmware_build/manifest.json"
|
||||
></esp-web-install-button>
|
||||
<script>
|
||||
const espWebInstallButton = document.querySelector("esp-web-install-button");
|
||||
espWebInstallButton.addEventListener(
|
||||
"state-changed", (ev) => { console.log(ev.detail) }
|
||||
);
|
||||
</script>
|
||||
</pre>
|
||||
<div class="footer">
|
||||
<div>
|
||||
ESP Web Tools –
|
||||
|
1301
package-lock.json
generated
1301
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
75
src/const.ts
75
src/const.ts
@ -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>;
|
||||
}
|
||||
}
|
||||
|
@ -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
85
src/flash-progress.ts
Normal 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` `}
|
||||
</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
234
src/flash.ts
Normal 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!",
|
||||
});
|
||||
};
|
@ -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.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;
|
||||
}
|
||||
this.toggleAttribute("install-supported", true);
|
||||
|
||||
this.addEventListener("mouseover", InstallButton.preload);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
};
|
||||
|
21
src/util.ts
21
src/util.ts
@ -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);
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user