Преглед изворни кода

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

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Bram Kragten пре 4 година
родитељ
комит
4cc28e148b
11 измењених фајлова са 1012 додато и 644 уклоњено
  1. 45 0
      README.md
  2. 140 6
      index.html
  3. 156 402
      package-lock.json
  4. 2 5
      package.json
  5. 75 0
      src/const.ts
  6. 56 20
      src/flash-log.ts
  7. 85 0
      src/flash-progress.ts
  8. 234 0
      src/flash.ts
  9. 96 22
      src/install-button.ts
  10. 102 189
      src/start-flash.ts
  11. 21 0
      src/util.ts

+ 45 - 0
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;&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
 

+ 140 - 6
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>
 &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 –

Разлика између датотеке није приказан због своје велике величине
+ 156 - 402
package-lock.json


+ 2 - 5
package.json

@@ -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 - 0
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>;
+  }
+}

+ 56 - 20
src/flash-log.ts

@@ -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 });
+  }
+
+  /**
+   * Add an action row
+   */
+  public addAction(message: Row["message"]) {
+    this.addRow({ message, action: true });
   }
 
   /**
-   * Remove last row if ID matches
+   * Remove last row if state matches
    */
-  public removeRow(id: string) {
-    if (this._rows.length > 0 && this._rows[this._rows.length - 1].id === id) {
+  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 - 0
src/flash-progress.ts

@@ -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 - 0
src/flash.ts

@@ -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!",
+  });
+};

+ 96 - 22
src/install-button.ts

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

+ 102 - 189
src/start-flash.ts

@@ -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";
-
-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()
-  );
+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";
 
-  let esploader: ESPLoader | undefined;
+let stateListenerAdded = false;
 
-  try {
-    esploader = await connect(logger);
-  } catch (err) {
-    // User pressed cancel on web serial
-    return;
-  }
+let logEl: FlashLog | undefined;
 
-  // For debugging
-  (window as any).esploader = esploader;
-
-  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;
-  }
+let progressEl: FlashProgress | undefined;
 
-  const chipFamily = getChipFamilyName(esploader);
+let improvEl: HTMLElement | undefined;
 
-  logEl.addRow({
-    id: "initializing",
-    content: html`Initialized. Found ${chipFamily}`,
-  });
-  logEl.addRow({ id: "manifest", content: "Fetching manifest..." });
+const addElement = <T extends HTMLElement>(
+  button: InstallButton,
+  element: T
+): T => {
+  button.renderRoot!.append(element);
+  return element;
+};
 
-  let manifest: Manifest | undefined;
-  try {
-    manifest = await manifestProm;
-  } catch (err) {
-    logEl.addError(`Unable to fetch manifest: ${err}`);
-    await esploader.disconnect();
+export const startFlash = async (button: InstallButton) => {
+  if (button.hasAttribute("active")) {
     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();
+  const manifest = button.manifest || button.getAttribute("manifest");
+  if (!manifest) {
+    alert("No manifest defined!");
     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");
+  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);
+    });
   }
 
-  if (eraseFirst) {
-    logEl.addRow({
-      id: "erase",
-      content: html`Erasing device...`,
-    });
-    await espStub.eraseFlash();
-    logEl.addRow({
-      id: "erase",
-      content: html`Device erased`,
-    });
+  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;
   }
 
-  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
+  if (showProgress && !progressEl) {
+    progressEl = addElement<FlashProgress>(
+      button,
+      document.createElement("esp-web-flash-progress")
     );
-    totalWritten += file.byteLength;
+  } else if (!showProgress && progressEl) {
+    progressEl.remove();
+    progressEl = undefined;
   }
 
-  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) {
+  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;
   }
 
-  // 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
-      >
-    `,
-  });
+  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 - 0
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);
+};

Неке датотеке нису приказане због велике количине промена