import { LitElement, html, PropertyValues, css, TemplateResult } from "lit"; import { state } from "lit/decorators.js"; import "./components/ewt-button"; import "./components/ewt-checkbox"; import "./components/ewt-console"; import "./components/ewt-dialog"; import "./components/ewt-formfield"; import "./components/ewt-icon-button"; import "./components/ewt-textfield"; import type { EwtTextfield } from "./components/ewt-textfield"; import "./components/ewt-select"; import "./components/ewt-list-item"; import "./pages/ewt-page-progress"; import "./pages/ewt-page-message"; import { chipIcon, closeIcon, firmwareIcon, refreshIcon, } from "./components/svg"; import { Logger, Manifest, FlashStateType, FlashState } from "./const.js"; import { ImprovSerial, Ssid } from "improv-wifi-serial-sdk/dist/serial"; import { ImprovSerialCurrentState, ImprovSerialErrorState, PortNotReady, } from "improv-wifi-serial-sdk/dist/const"; import { flash } from "./flash"; import { textDownload } from "./util/file-download"; import { fireEvent } from "./util/fire-event"; import { sleep } from "./util/sleep"; import { downloadManifest } from "./util/manifest"; import { dialogStyles } from "./styles"; const ERROR_ICON = "⚠️"; const OK_ICON = "🎉"; export class EwtInstallDialog extends LitElement { public port!: SerialPort; public manifestPath!: string; public logger: Logger = console; public overrides?: { checkSameFirmware?: ( manifest: Manifest, deviceImprov: ImprovSerial["info"] ) => boolean; }; private _manifest!: Manifest; private _info?: ImprovSerial["info"]; // null = NOT_SUPPORTED @state() private _client?: ImprovSerial | null; @state() private _state: | "ERROR" | "DASHBOARD" | "PROVISION" | "INSTALL" | "ASK_ERASE" | "LOGS" = "DASHBOARD"; @state() private _installErase = false; @state() private _installConfirmed = false; @state() private _installState?: FlashState; @state() private _provisionForce = false; private _wasProvisioned = false; @state() private _error?: string; @state() private _busy = false; // undefined = not loaded // null = not available @state() private _ssids?: Ssid[] | null; // Name of Ssid. Null = other @state() private _selectedSsid: string | null = null; protected render() { if (!this.port) { return html``; } let heading: string | undefined; let content: TemplateResult; let hideActions = false; let allowClosing = false; // During installation phase we temporarily remove the client if ( this._client === undefined && this._state !== "INSTALL" && this._state !== "LOGS" ) { if (this._error) { [heading, content, hideActions] = this._renderError(this._error); } else { content = this._renderProgress("Connecting"); hideActions = true; } } else if (this._state === "INSTALL") { [heading, content, hideActions, allowClosing] = this._renderInstall(); } else if (this._state === "ASK_ERASE") { [heading, content] = this._renderAskErase(); } else if (this._state === "ERROR") { [heading, content, hideActions] = this._renderError(this._error!); } else if (this._state === "DASHBOARD") { [heading, content, hideActions, allowClosing] = this._client ? this._renderDashboard() : this._renderDashboardNoImprov(); } else if (this._state === "PROVISION") { [heading, content, hideActions] = this._renderProvision(); } else if (this._state === "LOGS") { [heading, content, hideActions] = this._renderLogs(); } return html` ${heading && allowClosing ? html` ${closeIcon} ` : ""} ${content!} `; } _renderProgress(label: string | TemplateResult, progress?: number) { return html` `; } _renderError(label: string): [string, TemplateResult, boolean] { const heading = "Error"; const content = html` `; const hideActions = false; return [heading, content, hideActions]; } _renderDashboard(): [string, TemplateResult, boolean, boolean] { const heading = this._info!.name; let content: TemplateResult; let hideActions = true; let allowClosing = true; content = html`
${firmwareIcon}
${this._info!.firmware} ${this._info!.version}
${chipIcon}
${this._info!.chipFamily}
${!this._isSameVersion ? html`
{ if (this._isSameFirmware) { this._startInstall(false); } else if (this._manifest.new_install_prompt_erase) { this._state = "ASK_ERASE"; } else { this._startInstall(true); } }} >
` : ""} ${this._client!.nextUrl === undefined ? "" : html`
`} ${!this._manifest.home_assistant_domain || this._client!.state !== ImprovSerialCurrentState.PROVISIONED ? "" : html`
`}
{ this._state = "PROVISION"; if ( this._client!.state === ImprovSerialCurrentState.PROVISIONED ) { this._provisionForce = true; } }} >
{ const client = this._client; if (client) { await this._closeClientWithoutEvents(client); await sleep(100); } // Also set `null` back to undefined. this._client = undefined; this._state = "LOGS"; }} >
${this._isSameFirmware && this._manifest.funding_url ? html`
` : ""} ${this._isSameVersion ? html`
this._startInstall(true)} >
` : ""}
`; return [heading, content, hideActions, allowClosing]; } _renderDashboardNoImprov(): [string, TemplateResult, boolean, boolean] { const heading = "Device Dashboard"; let content: TemplateResult; let hideActions = true; let allowClosing = true; content = html`
{ if (this._manifest.new_install_prompt_erase) { this._state = "ASK_ERASE"; } else { // Default is to erase a device that does not support Improv Serial this._startInstall(true); } }} >
{ // Also set `null` back to undefined. this._client = undefined; this._state = "LOGS"; }} >
`; return [heading, content, hideActions, allowClosing]; } _renderProvision(): [string | undefined, TemplateResult, boolean] { let heading: string | undefined = "Configure Wi-Fi"; let content: TemplateResult; let hideActions = false; if (this._busy) { return [ heading, this._renderProgress( this._ssids === undefined ? "Scanning for networks" : "Trying to connect" ), true, ]; } if ( !this._provisionForce && this._client!.state === ImprovSerialCurrentState.PROVISIONED ) { heading = undefined; const showSetupLinks = !this._wasProvisioned && (this._client!.nextUrl !== undefined || "home_assistant_domain" in this._manifest); hideActions = showSetupLinks; content = html` ${showSetupLinks ? html`
${this._client!.nextUrl === undefined ? "" : html`
{ this._state = "DASHBOARD"; }} >
`} ${!this._manifest.home_assistant_domain ? "" : html`
{ this._state = "DASHBOARD"; }} >
`}
{ this._state = "DASHBOARD"; }} >
` : html` { this._state = "DASHBOARD"; }} > `} `; } else { let error: string | undefined; switch (this._client!.error) { case ImprovSerialErrorState.UNABLE_TO_CONNECT: error = "Unable to connect"; break; case ImprovSerialErrorState.TIMEOUT: error = "Timeout"; break; case ImprovSerialErrorState.NO_ERROR: // Happens when list SSIDs not supported. case ImprovSerialErrorState.UNKNOWN_RPC_COMMAND: break; default: error = `Unknown error (${this._client!.error})`; } const selectedSsid = this._ssids?.find( (info) => info.name === this._selectedSsid ); content = html`
Enter the credentials of the Wi-Fi network that you want your device to connect to.
${error ? html`

${error}

` : ""} ${this._ssids !== null ? html` { const index = ev.detail.index; // The "Join Other" item is always the last item. this._selectedSsid = index === this._ssids!.length ? null : this._ssids![index].name; }} @closed=${(ev: Event) => ev.stopPropagation()} > ${this._ssids!.map( (info) => html` ${info.name} ` )} Join other… ${refreshIcon} ` : ""} ${ // Show input box if command not supported or "Join Other" selected !selectedSsid ? html` ` : "" } ${!selectedSsid || selectedSsid.secured ? html` ` : ""} { this._state = "DASHBOARD"; }} > `; } return [heading, content, hideActions]; } _renderAskErase(): [string | undefined, TemplateResult] { const heading = "Erase device"; const content = html`
Do you want to erase the device before installing ${this._manifest.name}? All data on the device will be lost.
{ const checkbox = this.shadowRoot!.querySelector("ewt-checkbox")!; this._startInstall(checkbox.checked); }} > { this._state = "DASHBOARD"; }} > `; return [heading, content]; } _renderInstall(): [string | undefined, TemplateResult, boolean, boolean] { let heading: string | undefined; let content: TemplateResult; let hideActions = false; const allowClosing = false; const isUpdate = !this._installErase && this._isSameFirmware; if (!this._installConfirmed && this._isSameVersion) { heading = "Erase User Data"; content = html` Do you want to reset your device and erase all user data from your device? `; } else if (!this._installConfirmed) { heading = "Confirm Installation"; const action = isUpdate ? "update to" : "install"; content = html` ${isUpdate ? html`Your device is running ${this._info!.firmware} ${this._info!.version}.

` : ""} Do you want to ${action} ${this._manifest.name} ${this._manifest.version}? ${this._installErase ? html`

All data on the device will be erased.` : ""} { this._state = "DASHBOARD"; }} > `; } else if ( !this._installState || this._installState.state === FlashStateType.INITIALIZING || this._installState.state === FlashStateType.PREPARING ) { heading = "Installing"; content = this._renderProgress("Preparing installation"); hideActions = true; } else if (this._installState.state === FlashStateType.ERASING) { heading = "Installing"; content = this._renderProgress("Erasing"); hideActions = true; } else if ( this._installState.state === FlashStateType.WRITING || // When we're finished, keep showing this screen with 100% written // until Improv is initialized / not detected. (this._installState.state === FlashStateType.FINISHED && this._client === undefined) ) { heading = "Installing"; let percentage: number | undefined; let undeterminateLabel: string | undefined; if (this._installState.state === FlashStateType.FINISHED) { // We're done writing and detecting improv, show spinner undeterminateLabel = "Wrapping up"; } else if (this._installState.details.percentage < 4) { // We're writing the firmware under 4%, show spinner or else we don't show any pixels undeterminateLabel = "Installing"; } else { // We're writing the firmware over 4%, show progress bar percentage = this._installState.details.percentage; } content = this._renderProgress( html` ${undeterminateLabel ? html`${undeterminateLabel}
` : ""}
This will take ${this._installState.chipFamily === "ESP8266" ? "a minute" : "2 minutes"}.
Keep this page visible to prevent slow down `, percentage ); hideActions = true; } else if (this._installState.state === FlashStateType.FINISHED) { heading = undefined; const supportsImprov = this._client !== null; content = html` { this._state = supportsImprov && this._installErase ? "PROVISION" : "DASHBOARD"; }} > `; } else if (this._installState.state === FlashStateType.ERROR) { heading = "Installation failed"; content = html` { this._initialize(); this._state = "DASHBOARD"; }} > `; } return [heading, content!, hideActions, allowClosing]; } _renderLogs(): [string | undefined, TemplateResult, boolean] { let heading: string | undefined = `Logs`; let content: TemplateResult; let hideActions = false; content = html` { await this.shadowRoot!.querySelector("ewt-console")!.disconnect(); this._state = "DASHBOARD"; this._initialize(); }} > { textDownload( this.shadowRoot!.querySelector("ewt-console")!.logs(), `esp-web-tools-logs.txt` ); this.shadowRoot!.querySelector("ewt-console")!.reset(); }} > { await this.shadowRoot!.querySelector("ewt-console")!.reset(); }} > `; return [heading, content!, hideActions]; } public override willUpdate(changedProps: PropertyValues) { if (!changedProps.has("_state")) { return; } // Clear errors when changing between pages unless we change // to the error page. if (this._state !== "ERROR") { this._error = undefined; } // Scan for SSIDs on provision if (this._state === "PROVISION") { this._updateSsids(); } else { // Reset this value if we leave provisioning. this._provisionForce = false; } if (this._state === "INSTALL") { this._installConfirmed = false; this._installState = undefined; } } private async _updateSsids() { const oldSsids = this._ssids; this._ssids = undefined; this._busy = true; let ssids: Ssid[]; try { ssids = await this._client!.scan(); } catch (err) { // When we fail on first load, pick "Join other" if (this._ssids === undefined) { this._ssids = null; this._selectedSsid = null; } this._busy = false; return; } if (oldSsids) { // If we had a previous list, ensure the selection is still valid if ( this._selectedSsid && !ssids.find((s) => s.name === this._selectedSsid) ) { this._selectedSsid = ssids[0].name; } } else { this._selectedSsid = ssids.length ? ssids[0].name : null; } this._ssids = ssids; this._busy = false; } protected override firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); this._initialize(); } protected override updated(changedProps: PropertyValues) { super.updated(changedProps); if (changedProps.has("_state")) { this.setAttribute("state", this._state); } if (this._state !== "PROVISION") { return; } if (changedProps.has("_selectedSsid") && this._selectedSsid === null) { // If we pick "Join other", select SSID input. this._focusFormElement("ewt-textfield[name=ssid]"); } else if (changedProps.has("_ssids")) { // Form is shown when SSIDs are loaded/marked not supported this._focusFormElement(); } } private _focusFormElement(selector = "ewt-textfield, ewt-select") { const formEl = this.shadowRoot!.querySelector( selector ) as LitElement | null; if (formEl) { formEl.updateComplete.then(() => setTimeout(() => formEl.focus(), 100)); } } private async _initialize(justInstalled = false) { if (this.port.readable === null || this.port.writable === null) { this._state = "ERROR"; this._error = "Serial port is not readable/writable. Close any other application using it and try again."; return; } try { this._manifest = await downloadManifest(this.manifestPath); } catch (err: any) { this._state = "ERROR"; this._error = "Failed to download manifest"; return; } if (this._manifest.new_install_improv_wait_time === 0) { this._client = null; return; } const client = new ImprovSerial(this.port!, this.logger); client.addEventListener("state-changed", () => { this.requestUpdate(); }); client.addEventListener("error-changed", () => this.requestUpdate()); try { // If a device was just installed, give new firmware 10 seconds (overridable) to // format the rest of the flash and do other stuff. const timeout = !justInstalled ? 1000 : this._manifest.new_install_improv_wait_time !== undefined ? this._manifest.new_install_improv_wait_time * 1000 : 10000; this._info = await client.initialize(timeout); this._client = client; client.addEventListener("disconnect", this._handleDisconnect); } catch (err: any) { // Clear old value this._info = undefined; if (err instanceof PortNotReady) { this._state = "ERROR"; this._error = "Serial port is not ready. Close any other application using it and try again."; } else { this._client = null; // not supported this.logger.error("Improv initialization failed.", err); } } } private _startInstall(erase: boolean) { this._state = "INSTALL"; this._installErase = erase; this._installConfirmed = false; } private async _confirmInstall() { this._installConfirmed = true; this._installState = undefined; if (this._client) { await this._closeClientWithoutEvents(this._client); } this._client = undefined; // Close port. ESPLoader likes opening it. await this.port.close(); flash( (state) => { this._installState = state; if (state.state === FlashStateType.FINISHED) { sleep(100) // Flashing closes the port .then(() => this.port.open({ baudRate: 115200 })) .then(() => this._initialize(true)) .then(() => this.requestUpdate()); } else if (state.state === FlashStateType.ERROR) { sleep(100) // Flashing closes the port .then(() => this.port.open({ baudRate: 115200 })); } }, this.port, this.manifestPath, this._manifest, this._installErase ); } private async _doProvision() { this._busy = true; this._wasProvisioned = this._client!.state === ImprovSerialCurrentState.PROVISIONED; const ssid = this._selectedSsid === null ? ( this.shadowRoot!.querySelector( "ewt-textfield[name=ssid]" ) as EwtTextfield ).value : this._selectedSsid; const password = ( this.shadowRoot!.querySelector( "ewt-textfield[name=password]" ) as EwtTextfield | null )?.value || ""; try { await this._client!.provision(ssid, password, 30000); } catch (err: any) { return; } finally { this._busy = false; this._provisionForce = false; } } private _handleDisconnect = () => { this._state = "ERROR"; this._error = "Disconnected"; }; private async _handleClose() { if (this._client) { await this._closeClientWithoutEvents(this._client); } fireEvent(this, "closed" as any); this.parentNode!.removeChild(this); } /** * Return if the device runs same firmware as manifest. */ private get _isSameFirmware() { return !this._info ? false : this.overrides?.checkSameFirmware ? this.overrides.checkSameFirmware(this._manifest, this._info) : this._info.firmware === this._manifest.name; } /** * Return if the device runs same firmware and version as manifest. */ private get _isSameVersion() { return ( this._isSameFirmware && this._info!.version === this._manifest.version ); } private async _closeClientWithoutEvents(client: ImprovSerial) { client.removeEventListener("disconnect", this._handleDisconnect); await client.close(); } static styles = [ dialogStyles, css` :host { --mdc-dialog-max-width: 390px; } ewt-icon-button { position: absolute; right: 4px; top: 10px; } .table-row { display: flex; } .table-row.last { margin-bottom: 16px; } .table-row svg { width: 20px; margin-right: 8px; } ewt-textfield, ewt-select { display: block; margin-top: 16px; } .dashboard-buttons { margin: 0 0 -16px -8px; } .dashboard-buttons div { display: block; margin: 4px 0; } a.has-button { text-decoration: none; } .error { color: var(--improv-danger-color); } .danger { --mdc-theme-primary: var(--improv-danger-color); --mdc-theme-secondary: var(--improv-danger-color); } button.link { background: none; color: inherit; border: none; padding: 0; font: inherit; text-align: left; text-decoration: underline; cursor: pointer; } :host([state="LOGS"]) ewt-dialog { --mdc-dialog-max-width: 90vw; } ewt-console { width: calc(80vw - 48px); height: 80vh; } ewt-list-item[value="-1"] { border-top: 1px solid #ccc; } `, ]; } customElements.define("ewt-install-dialog", EwtInstallDialog); declare global { interface HTMLElementTagNameMap { "ewt-install-dialog": EwtInstallDialog; } }