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}
`;
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`
`;
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`
`
: 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;
}
}