{
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("Trying to connect")];
}
if (this._client!.state === ImprovSerialCurrentState.STOPPED) {
heading = undefined;
content = html`
`;
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 networks from scratch every time we enter provisioning.
// `_syncScanning` picks it up from here.
if (this._state === "PROVISION") {
this._ssids = undefined;
} else {
// Reset this value if we leave provisioning.
this._provisionForce = false;
}
if (this._state === "INSTALL") {
this._installConfirmed = false;
this._installState = undefined;
}
}
/**
* Return if the provision page shows the network form (and not a message).
*/
private get _showsProvisionForm() {
const clientState = this._client?.state;
return (
clientState !== undefined &&
clientState !== ImprovSerialCurrentState.STOPPED &&
(this._provisionForce ||
clientState !== ImprovSerialCurrentState.PROVISIONED)
);
}
// Scan while (and only while) the network form is shown. Driven from
// `updated()`, so entering the form starts scanning and leaving it stops.
private _syncScanning() {
const shouldScan =
this._state === "PROVISION" && !this._busy && this._showsProvisionForm;
if (shouldScan === !!this._unsubSSIDs) {
return;
}
if (!shouldScan) {
this._stopScanning();
return;
}
// Give a device that comes back empty-handed a little longer before we
// show the form and tell the user there are no networks.
this._scanGraceTimeout = setTimeout(() => {
this._scanGraceTimeout = undefined;
if (this._ssids === undefined) {
this._ssids = [];
this._selectedSsid = null;
}
}, SCAN_GRACE_PERIOD);
// `null` means the device can't scan, and we ask for the network manually.
this._unsubSSIDs = this._client!.subscribeSSIDs((ssids: Ssid[] | null) => {
// Keep waiting while a device that hasn't found anything yet is still
// within its grace period.
if (
this._ssids === undefined &&
ssids?.length === 0 &&
this._scanGraceTimeout
) {
return;
}
// A scan error after we already have networks is transient, not "device
// can't scan" (e.g. a late failure packet from a just-failed provision
// rejecting our scan). Keep the list we have; scanning stops until we
// re-enter the form.
if (ssids === null && this._ssids) {
return;
}
if (this._ssids === undefined) {
// First result. Preselect the strongest network, or "Join other" if
// the device can't scan or found nothing.
this._selectedSsid = ssids === null ? null : strongestSsid(ssids);
} else if (
this._selectedSsid !== null &&
!ssids?.some((ssid) => ssid.name === this._selectedSsid)
) {
// The selected network dropped off the list. A subscription merges scans
// from scratch, so this happens when we resume scanning (ie. after a
// failed provision) and the first scan misses it. Fall back to "Join
// other" prefilled with the network they picked, so they keep the data
// they entered and can simply hit Connect again.
this._manualSsid = this._selectedSsid;
this._selectedSsid = null;
}
this._ssids = ssids;
});
}
private async _stopScanning() {
clearTimeout(this._scanGraceTimeout);
this._scanGraceTimeout = undefined;
const unsubscribe = this._unsubSSIDs;
if (!unsubscribe) {
return;
}
this._unsubSSIDs = undefined;
await unsubscribe();
}
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);
}
this._syncScanning();
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") &&
changedProps.get("_ssids") === undefined
) {
// Form is shown when SSIDs are first loaded/marked not supported. Later
// scans must not steal focus back from whatever the user is filling in.
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 {
// Improv re-sends the request every second until this timeout, so 1500ms
// gets us a single retry. That gives a device that was busy on our first
// try (ie. still streaming the results of a Wi-Fi scan from before a page
// reload) a second chance to answer us.
// 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
? 1500
: 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, bufferSize: 8192 }))
.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, bufferSize: 8192 }));
}
},
this.port,
this.manifestPath,
this._manifest,
this._installErase,
);
}
private async _doProvision() {
// Read the form before setting `_busy`: that swaps the form for a progress
// view, and the render happens during any await below — after which these
// fields no longer exist and we'd provision with an empty password.
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 || "";
this._busy = true;
this._wasProvisioned =
this._client!.state === ImprovSerialCurrentState.PROVISIONED;
// Wait for any in-flight scan to settle before provisioning so we don't
// have two RPC commands in flight at once. Marking busy above already tells
// `_syncScanning` to stop, but the provision RPC needs it stopped *now*.
await this._stopScanning();
try {
// Devices try to connect for ~30s before reporting failure. Our timeout
// must comfortably exceed that: it's only a safety net, and if it fires
// first the device's late failure packet rejects the *next* RPC we send
// (the resumed network scan) instead of this one.
await this._client!.provision(ssid, password, 45000);
} catch (err: any) {
return;
} finally {
// If we end up back on the network form, `_syncScanning` resumes scanning.
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) {
// Let an in-flight scan settle before we pull the port out from under it.
await this._stopScanning();
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;
}
ew-select-option svg {
width: 24px;
height: 24px;
display: block;
}
.network-details {
display: flex;
align-items: center;
gap: 8px;
margin-left: 16px;
color: var(--text-color);
font-size: 12px;
}
.signal-excellent,
.signal-good,
.lock-secured {
color: #34a853;
}
.signal-fair {
color: #fbbc04;
}
.signal-weak,
.lock-unsecured {
color: var(--danger-color);
}
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;
}
}