import { LitElement, html, PropertyValues, css, TemplateResult } from "lit"; import { state } from "lit/decorators.js"; import "./components/ew-text-button"; import "./components/ew-list"; import "./components/ew-list-item"; import "./components/ew-divider"; import "./components/ew-checkbox"; import "./components/ewt-console"; import "./components/ew-dialog"; import "./components/ew-icon-button"; import "./components/ew-filled-text-field"; import type { EwFilledTextField } from "./components/ew-filled-text-field"; import "./components/ew-filled-select"; import "./components/ew-select-option"; import "./pages/ewt-page-progress"; import "./pages/ewt-page-message"; import { closeIcon, listItemConsole, listItemHomeAssistant, listItemInstallIcon, listItemVisitDevice, listItemWifi, 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"; import { version } from "./version"; import type { EwFilledSelect } from "./components/ew-filled-select"; console.log( `ESP Web Tools ${version} by Nabu Casa; https://esphome.github.io/esp-web-tools/`, ); 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; private _bodyOverflow: string | null = null; protected render() { if (!this.port) { return html``; } let heading: string | undefined; let content: TemplateResult; 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] = this._renderError(this._error); } else { content = this._renderProgress("Connecting"); } } else if (this._state === "INSTALL") { [heading, content, allowClosing] = this._renderInstall(); } else if (this._state === "ASK_ERASE") { [heading, content] = this._renderAskErase(); } else if (this._state === "ERROR") { [heading, content] = this._renderError(this._error!); } else if (this._state === "DASHBOARD") { [heading, content, allowClosing] = this._client ? this._renderDashboard() : this._renderDashboardNoImprov(); } else if (this._state === "PROVISION") { [heading, content] = this._renderProvision(); } else if (this._state === "LOGS") { [heading, content] = this._renderLogs(); } return html` ${heading ? html`
${heading}
` : ""} ${allowClosing ? html` ${closeIcon} ` : ""} ${content!}
`; } _renderProgress(label: string | TemplateResult, progress?: number) { return html` `; } _renderError(label: string): [string, TemplateResult] { const heading = "Error"; const content = html`
Close
`; return [heading, content]; } _renderDashboard(): [string, TemplateResult, boolean] { const heading = this._manifest.name; let content: TemplateResult; let allowClosing = true; content = html`
Connected to ${this._info!.name}
${this._info!.firmware} ${this._info!.version} (${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); } }} > ${listItemInstallIcon}
${!this._isSameFirmware ? `Install ${this._manifest.name}` : `Update ${this._manifest.name}`}
` : ""} ${this._client!.nextUrl === undefined ? "" : html` ${listItemVisitDevice}
Visit Device
`} ${!this._manifest.home_assistant_domain || this._client!.state !== ImprovSerialCurrentState.PROVISIONED ? "" : html` ${listItemHomeAssistant}
Add to Home Assistant
`} { this._state = "PROVISION"; if ( this._client!.state === ImprovSerialCurrentState.PROVISIONED ) { this._provisionForce = true; } }} > ${listItemWifi}
${this._client!.state === ImprovSerialCurrentState.READY ? "Connect to Wi-Fi" : "Change Wi-Fi"}
{ 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"; }} > ${listItemConsole}
Logs & Console
${this._isSameFirmware && this._manifest.funding_url ? html`
Fund Development
` : ""} ${this._isSameVersion ? html` this._startInstall(true)} >
Erase User Data
` : ""}
`; return [heading, content, allowClosing]; } _renderDashboardNoImprov(): [string, TemplateResult, boolean] { const heading = this._manifest.name; let content: TemplateResult; 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); } }} > ${listItemInstallIcon}
${`Install ${this._manifest.name}`}
{ // Also set `null` back to undefined. this._client = undefined; this._state = "LOGS"; }} > ${listItemConsole}
Logs & Console
`; return [heading, content, allowClosing]; } _renderProvision(): [string | undefined, TemplateResult] { let heading: string | undefined = "Configure Wi-Fi"; let content: TemplateResult; if (this._busy) { return [ heading, this._renderProgress( this._ssids === undefined ? "Scanning for networks" : "Trying to connect", ), ]; } if ( !this._provisionForce && this._client!.state === ImprovSerialCurrentState.PROVISIONED ) { heading = undefined; const showSetupLinks = !this._wasProvisioned && (this._client!.nextUrl !== undefined || "home_assistant_domain" in this._manifest); content = html`
${showSetupLinks ? html` ${this._client!.nextUrl === undefined ? "" : html` { this._state = "DASHBOARD"; }} > ${listItemVisitDevice}
Visit Device
`} ${!this._manifest.home_assistant_domain ? "" : html` { this._state = "DASHBOARD"; }} > ${listItemHomeAssistant}
Add to Home Assistant
`} { this._state = "DASHBOARD"; }} >
Skip
` : ""}
${!showSetupLinks ? html`
{ this._state = "DASHBOARD"; }} > Continue
` : ""} `; } 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` ${refreshIcon}
Connect your device to the network to start using it.
${error ? html`

${error}

` : ""} ${this._ssids !== null ? html` { const index = ev.target.selectedIndex; // The "Join Other" item is always the last item. this._selectedSsid = index === this._ssids!.length ? null : this._ssids![index].name; }} > ${this._ssids!.map( (info) => html` ${info.name} `, )} Join other… ` : ""} ${ // Show input box if command not supported or "Join Other" selected !selectedSsid ? html` ` : "" } ${!selectedSsid || selectedSsid.secured ? html` ` : ""}
{ this._state = "DASHBOARD"; }} > ${this._installState && this._installErase ? "Skip" : "Back"} Connect
`; } return [heading, content]; } _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.
{ this._state = "DASHBOARD"; }} > Back { const checkbox = this.shadowRoot!.querySelector("ew-checkbox")!; this._startInstall(checkbox.checked); }} > Next
`; return [heading, content]; } _renderInstall(): [string | undefined, TemplateResult, boolean] { let heading: string | undefined; let content: TemplateResult; 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?
Erase User Data
`; } 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"; }} > Back Install
`; } else if ( !this._installState || this._installState.state === FlashStateType.INITIALIZING || this._installState.state === FlashStateType.PREPARING ) { heading = "Installing"; content = this._renderProgress("Preparing installation"); } else if (this._installState.state === FlashStateType.ERASING) { heading = "Installing"; content = this._renderProgress("Erasing"); } 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, ); } else if (this._installState.state === FlashStateType.FINISHED) { heading = undefined; const supportsImprov = this._client !== null; content = html`
{ this._state = supportsImprov && this._installErase ? "PROVISION" : "DASHBOARD"; }} > Next
`; } else if (this._installState.state === FlashStateType.ERROR) { heading = "Installation failed"; content = html`
{ this._initialize(); this._state = "DASHBOARD"; }} > Back
`; } return [heading, content!, allowClosing]; } _renderLogs(): [string | undefined, TemplateResult] { let heading: string | undefined = `Logs`; let content: TemplateResult; content = html`
{ await this.shadowRoot!.querySelector("ewt-console")!.reset(); }} > Reset Device { textDownload( this.shadowRoot!.querySelector("ewt-console")!.logs(), `esp-web-tools-logs.txt`, ); this.shadowRoot!.querySelector("ewt-console")!.reset(); }} > Download Logs { await this.shadowRoot!.querySelector("ewt-console")!.disconnect(); this._state = "DASHBOARD"; this._initialize(); }} > Back
`; return [heading, content!]; } 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(tries = 0) { const oldSsids = this._ssids; this._ssids = undefined; this._busy = true; let ssids: Ssid[]; try { ssids = await this._client!.scan(); } catch (err) { // When we fail while loading, pick "Join other" if (this._ssids === undefined) { this._ssids = null; this._selectedSsid = null; } this._busy = false; return; } // We will retry a few times if we don't get any results if (ssids.length === 0 && tries < 3) { console.log("SCHEDULE RETRY", tries); setTimeout(() => this._updateSsids(tries + 1), 1000); 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._bodyOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; 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("ew-filled-text-field[name=ssid]"); } else if (changedProps.has("_ssids")) { // Form is shown when SSIDs are loaded/marked not supported this._focusFormElement(); } } private _focusFormElement( selector = "ew-filled-text-field, ew-filled-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( "ew-filled-text-field[name=ssid]", ) as EwFilledTextField ).value : this._selectedSsid; const password = ( this.shadowRoot!.querySelector( "ew-filled-text-field[name=password]", ) as EwFilledTextField | 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 _closeDialog() { this.shadowRoot!.querySelector("ew-dialog")!.close(); } private async _handleClose() { if (this._client) { await this._closeClientWithoutEvents(this._client); } fireEvent(this, "closed" as any); document.body.style.overflow = this._bodyOverflow!; 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(); } private _preventDefault(ev: Event) { ev.preventDefault(); } static styles = [ dialogStyles, css` :host { --mdc-dialog-max-width: 390px; } div[slot="headline"] { padding-right: 48px; } ew-icon-button[slot="headline"] { position: absolute; right: 4px; top: 8px; } ew-icon-button[slot="headline"] svg { padding: 8px; color: var(--text-color); } .dialog-nav svg { color: var(--text-color); } .table-row { display: flex; } .table-row.last { margin-bottom: 16px; } .table-row svg { width: 20px; margin-right: 8px; } ew-filled-text-field, ew-filled-select { display: block; margin-top: 16px; } label.formfield { display: inline-flex; align-items: center; padding-right: 8px; } ew-list { margin: 0 -24px; padding: 0; } ew-list-item svg { height: 24px; } ewt-page-message + ew-list { padding-top: 16px; } .fake-icon { width: 24px; } .error { color: var(--danger-color); } .danger { --mdc-theme-primary: var(--danger-color); --mdc-theme-secondary: var(--danger-color); --md-sys-color-primary: var(--danger-color); --md-sys-color-on-surface: var(--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"]) ew-dialog { max-width: 90vw; max-height: 90vh; } ewt-console { width: calc(80vw - 48px); height: calc(90vh - 168px); } `, ]; } customElements.define("ewt-install-dialog", EwtInstallDialog); declare global { interface HTMLElementTagNameMap { "ewt-install-dialog": EwtInstallDialog; } }