1 | import { LitElement, html, PropertyValues, css, TemplateResult } from "lit";
|
2 | import { state } from "lit/decorators.js";
|
3 | import "./components/ew-text-button";
|
4 | import "./components/ew-list";
|
5 | import "./components/ew-list-item";
|
6 | import "./components/ew-divider";
|
7 | import "./components/ew-checkbox";
|
8 | import "./components/ewt-console";
|
9 | import "./components/ew-dialog";
|
10 | import "./components/ew-icon-button";
|
11 | import "./components/ew-filled-text-field";
|
12 | import type { EwFilledTextField } from "./components/ew-filled-text-field";
|
13 | import "./components/ew-filled-select";
|
14 | import "./components/ew-select-option";
|
15 | import "./pages/ewt-page-progress";
|
16 | import "./pages/ewt-page-message";
|
17 | import {
|
18 | closeIcon,
|
19 | listItemConsole,
|
20 | listItemEraseUserData,
|
21 | listItemFundDevelopment,
|
22 | listItemHomeAssistant,
|
23 | listItemInstallIcon,
|
24 | listItemVisitDevice,
|
25 | listItemWifi,
|
26 | refreshIcon,
|
27 | } from "./components/svg";
|
28 | import { Logger, Manifest, FlashStateType, FlashState } from "./const.js";
|
29 | import { ImprovSerial, Ssid } from "improv-wifi-serial-sdk/dist/serial";
|
30 | import {
|
31 | ImprovSerialCurrentState,
|
32 | ImprovSerialErrorState,
|
33 | PortNotReady,
|
34 | } from "improv-wifi-serial-sdk/dist/const";
|
35 | import { flash } from "./flash";
|
36 | import { textDownload } from "./util/file-download";
|
37 | import { fireEvent } from "./util/fire-event";
|
38 | import { sleep } from "./util/sleep";
|
39 | import { downloadManifest } from "./util/manifest";
|
40 | import { dialogStyles } from "./styles";
|
41 | import { version } from "./version";
|
42 | import type { EwFilledSelect } from "./components/ew-filled-select";
|
43 |
|
44 | console.log(
|
45 | `ESP Web Tools ${version} by Open Home Foundation; https://esphome.github.io/esp-web-tools/`,
|
46 | );
|
47 |
|
48 | const ERROR_ICON = "⚠️";
|
49 | const OK_ICON = "🎉";
|
50 |
|
51 | export class EwtInstallDialog extends LitElement {
|
52 | public port!: SerialPort;
|
53 |
|
54 | public manifestPath!: string;
|
55 |
|
56 | public logger: Logger = console;
|
57 |
|
58 | public overrides?: {
|
59 | checkSameFirmware?: (
|
60 | manifest: Manifest,
|
61 | deviceImprov: ImprovSerial["info"],
|
62 | ) => boolean;
|
63 | };
|
64 |
|
65 | private _manifest!: Manifest;
|
66 |
|
67 | private _info?: ImprovSerial["info"];
|
68 |
|
69 |
|
70 | @state() private _client?: ImprovSerial | null;
|
71 |
|
72 | @state() private _state:
|
73 | | "ERROR"
|
74 | | "DASHBOARD"
|
75 | | "PROVISION"
|
76 | | "INSTALL"
|
77 | | "ASK_ERASE"
|
78 | | "LOGS" = "DASHBOARD";
|
79 |
|
80 | @state() private _installErase = false;
|
81 | @state() private _installConfirmed = false;
|
82 | @state() private _installState?: FlashState;
|
83 |
|
84 | @state() private _provisionForce = false;
|
85 | private _wasProvisioned = false;
|
86 |
|
87 | @state() private _error?: string;
|
88 |
|
89 | @state() private _busy = false;
|
90 |
|
91 |
|
92 |
|
93 | @state() private _ssids?: Ssid[] | null;
|
94 |
|
95 |
|
96 | @state() private _selectedSsid: string | null = null;
|
97 |
|
98 | private _bodyOverflow: string | null = null;
|
99 |
|
100 | protected render() {
|
101 | if (!this.port) {
|
102 | return html``;
|
103 | }
|
104 | let heading: string | undefined;
|
105 | let content: TemplateResult;
|
106 | let allowClosing = false;
|
107 |
|
108 |
|
109 | if (
|
110 | this._client === undefined &&
|
111 | this._state !== "INSTALL" &&
|
112 | this._state !== "LOGS"
|
113 | ) {
|
114 | if (this._error) {
|
115 | [heading, content] = this._renderError(this._error);
|
116 | } else {
|
117 | content = this._renderProgress("Connecting");
|
118 | }
|
119 | } else if (this._state === "INSTALL") {
|
120 | [heading, content, allowClosing] = this._renderInstall();
|
121 | } else if (this._state === "ASK_ERASE") {
|
122 | [heading, content] = this._renderAskErase();
|
123 | } else if (this._state === "ERROR") {
|
124 | [heading, content] = this._renderError(this._error!);
|
125 | } else if (this._state === "DASHBOARD") {
|
126 | [heading, content, allowClosing] = this._client
|
127 | ? this._renderDashboard()
|
128 | : this._renderDashboardNoImprov();
|
129 | } else if (this._state === "PROVISION") {
|
130 | [heading, content] = this._renderProvision();
|
131 | } else if (this._state === "LOGS") {
|
132 | [heading, content] = this._renderLogs();
|
133 | }
|
134 |
|
135 | return html`
|
136 | <ew-dialog
|
137 | open
|
138 | .heading=${heading!}
|
139 | @cancel=${this._preventDefault}
|
140 | @closed=${this._handleClose}
|
141 | >
|
142 | ${heading ? html`<div slot="headline">${heading}</div>` : ""}
|
143 | ${allowClosing
|
144 | ? html`
|
145 | <ew-icon-button slot="headline" @click=${this._closeDialog}>
|
146 | ${closeIcon}
|
147 | </ew-icon-button>
|
148 | `
|
149 | : ""}
|
150 | ${content!}
|
151 | </ew-dialog>
|
152 | `;
|
153 | }
|
154 |
|
155 | _renderProgress(label: string | TemplateResult, progress?: number) {
|
156 | return html`
|
157 | <ewt-page-progress
|
158 | slot="content"
|
159 | .label=${label}
|
160 | .progress=${progress}
|
161 | ></ewt-page-progress>
|
162 | `;
|
163 | }
|
164 |
|
165 | _renderError(label: string): [string, TemplateResult] {
|
166 | const heading = "Error";
|
167 | const content = html`
|
168 | <ewt-page-message
|
169 | slot="content"
|
170 | .icon=${ERROR_ICON}
|
171 | .label=${label}
|
172 | ></ewt-page-message>
|
173 | <div slot="actions">
|
174 | <ew-text-button @click=${this._closeDialog}>Close</ew-text-button>
|
175 | </div>
|
176 | `;
|
177 | return [heading, content];
|
178 | }
|
179 |
|
180 | _renderDashboard(): [string, TemplateResult, boolean] {
|
181 | const heading = this._manifest.name;
|
182 | let content: TemplateResult;
|
183 | let allowClosing = true;
|
184 |
|
185 | content = html`
|
186 | <div slot="content">
|
187 | <ew-list>
|
188 | <ew-list-item>
|
189 | <div slot="headline">Connected to ${this._info!.name}</div>
|
190 | <div slot="supporting-text">
|
191 | ${this._info!.firmware} ${this._info!.version}
|
192 | (${this._info!.chipFamily})
|
193 | </div>
|
194 | </ew-list-item>
|
195 | ${!this._isSameVersion
|
196 | ? html`
|
197 | <ew-list-item
|
198 | type="button"
|
199 | @click=${() => {
|
200 | if (this._isSameFirmware) {
|
201 | this._startInstall(false);
|
202 | } else if (this._manifest.new_install_prompt_erase) {
|
203 | this._state = "ASK_ERASE";
|
204 | } else {
|
205 | this._startInstall(true);
|
206 | }
|
207 | }}
|
208 | >
|
209 | ${listItemInstallIcon}
|
210 | <div slot="headline">
|
211 | ${!this._isSameFirmware
|
212 | ? `Install ${this._manifest.name}`
|
213 | : `Update ${this._manifest.name}`}
|
214 | </div>
|
215 | </ew-list-item>
|
216 | `
|
217 | : ""}
|
218 | ${this._client!.nextUrl === undefined
|
219 | ? ""
|
220 | : html`
|
221 | <ew-list-item
|
222 | type="link"
|
223 | href=${this._client!.nextUrl}
|
224 | target="_blank"
|
225 | >
|
226 | ${listItemVisitDevice}
|
227 | <div slot="headline">Visit Device</div>
|
228 | </ew-list-item>
|
229 | `}
|
230 | ${!this._manifest.home_assistant_domain ||
|
231 | this._client!.state !== ImprovSerialCurrentState.PROVISIONED
|
232 | ? ""
|
233 | : html`
|
234 | <ew-list-item
|
235 | type="link"
|
236 | href=${`https://my.home-assistant.io/redirect/config_flow_start/?domain=${this._manifest.home_assistant_domain}`}
|
237 | target="_blank"
|
238 | >
|
239 | ${listItemHomeAssistant}
|
240 | <div slot="headline">Add to Home Assistant</div>
|
241 | </ew-list-item>
|
242 | `}
|
243 | <ew-list-item
|
244 | type="button"
|
245 | @click=${() => {
|
246 | this._state = "PROVISION";
|
247 | if (
|
248 | this._client!.state === ImprovSerialCurrentState.PROVISIONED
|
249 | ) {
|
250 | this._provisionForce = true;
|
251 | }
|
252 | }}
|
253 | >
|
254 | ${listItemWifi}
|
255 | <div slot="headline">
|
256 | ${this._client!.state === ImprovSerialCurrentState.READY
|
257 | ? "Connect to Wi-Fi"
|
258 | : "Change Wi-Fi"}
|
259 | </div>
|
260 | </ew-list-item>
|
261 | <ew-list-item
|
262 | type="button"
|
263 | @click=${async () => {
|
264 | const client = this._client;
|
265 | if (client) {
|
266 | await this._closeClientWithoutEvents(client);
|
267 | await sleep(100);
|
268 | }
|
269 | // Also set `null` back to undefined.
|
270 | this._client = undefined;
|
271 | this._state = "LOGS";
|
272 | }}
|
273 | >
|
274 | ${listItemConsole}
|
275 | <div slot="headline">Logs & Console</div>
|
276 | </ew-list-item>
|
277 | ${this._isSameFirmware && this._manifest.funding_url
|
278 | ? html`
|
279 | <ew-list-item
|
280 | type="link"
|
281 | href=${this._manifest.funding_url}
|
282 | target="_blank"
|
283 | >
|
284 | ${listItemFundDevelopment}
|
285 | <div slot="headline">Fund Development</div>
|
286 | </ew-list-item>
|
287 | `
|
288 | : ""}
|
289 | ${this._isSameVersion
|
290 | ? html`
|
291 | <ew-list-item
|
292 | type="button"
|
293 | class="danger"
|
294 | @click=${() => this._startInstall(true)}
|
295 | >
|
296 | ${listItemEraseUserData}
|
297 | <div slot="headline">Erase User Data</div>
|
298 | </ew-list-item>
|
299 | `
|
300 | : ""}
|
301 | </ew-list>
|
302 | </div>
|
303 | `;
|
304 |
|
305 | return [heading, content, allowClosing];
|
306 | }
|
307 | _renderDashboardNoImprov(): [string, TemplateResult, boolean] {
|
308 | const heading = this._manifest.name;
|
309 | let content: TemplateResult;
|
310 | let allowClosing = true;
|
311 |
|
312 | content = html`
|
313 | <div slot="content">
|
314 | <ew-list>
|
315 | <ew-list-item
|
316 | type="button"
|
317 | @click=${() => {
|
318 | if (this._manifest.new_install_prompt_erase) {
|
319 | this._state = "ASK_ERASE";
|
320 | } else {
|
321 |
|
322 | this._startInstall(true);
|
323 | }
|
324 | }}
|
325 | >
|
326 | ${listItemInstallIcon}
|
327 | <div slot="headline">${`Install ${this._manifest.name}`}</div>
|
328 | </ew-list-item>
|
329 | <ew-list-item
|
330 | type="button"
|
331 | @click=${async () => {
|
332 |
|
333 | this._client = undefined;
|
334 | this._state = "LOGS";
|
335 | }}
|
336 | >
|
337 | ${listItemConsole}
|
338 | <div slot="headline">Logs & Console</div>
|
339 | </ew-list-item>
|
340 | </ew-list>
|
341 | </div>
|
342 | `;
|
343 |
|
344 | return [heading, content, allowClosing];
|
345 | }
|
346 |
|
347 | _renderProvision(): [string | undefined, TemplateResult] {
|
348 | let heading: string | undefined = "Configure Wi-Fi";
|
349 | let content: TemplateResult;
|
350 |
|
351 | if (this._busy) {
|
352 | return [
|
353 | heading,
|
354 | this._renderProgress(
|
355 | this._ssids === undefined
|
356 | ? "Scanning for networks"
|
357 | : "Trying to connect",
|
358 | ),
|
359 | ];
|
360 | }
|
361 |
|
362 | if (
|
363 | !this._provisionForce &&
|
364 | this._client!.state === ImprovSerialCurrentState.PROVISIONED
|
365 | ) {
|
366 | heading = undefined;
|
367 | const showSetupLinks =
|
368 | !this._wasProvisioned &&
|
369 | (this._client!.nextUrl !== undefined ||
|
370 | "home_assistant_domain" in this._manifest);
|
371 | content = html`
|
372 | <div slot="content">
|
373 | <ewt-page-message
|
374 | .icon=${OK_ICON}
|
375 | label="Device connected to the network!"
|
376 | ></ewt-page-message>
|
377 | ${showSetupLinks
|
378 | ? html`
|
379 | <ew-list>
|
380 | ${this._client!.nextUrl === undefined
|
381 | ? ""
|
382 | : html`
|
383 | <ew-list-item
|
384 | type="link"
|
385 | href=${this._client!.nextUrl}
|
386 | target="_blank"
|
387 | @click=${() => {
|
388 | this._state = "DASHBOARD";
|
389 | }}
|
390 | >
|
391 | ${listItemVisitDevice}
|
392 | <div slot="headline">Visit Device</div>
|
393 | </ew-list-item>
|
394 | `}
|
395 | ${!this._manifest.home_assistant_domain
|
396 | ? ""
|
397 | : html`
|
398 | <ew-list-item
|
399 | type="link"
|
400 | href=${`https://my.home-assistant.io/redirect/config_flow_start/?domain=${this._manifest.home_assistant_domain}`}
|
401 | target="_blank"
|
402 | @click=${() => {
|
403 | this._state = "DASHBOARD";
|
404 | }}
|
405 | >
|
406 | ${listItemHomeAssistant}
|
407 | <div slot="headline">Add to Home Assistant</div>
|
408 | </ew-list-item>
|
409 | `}
|
410 | <ew-list-item
|
411 | type="button"
|
412 | @click=${() => {
|
413 | this._state = "DASHBOARD";
|
414 | }}
|
415 | >
|
416 | <div slot="start" class="fake-icon"></div>
|
417 | <div slot="headline">Skip</div>
|
418 | </ew-list-item>
|
419 | </ew-list>
|
420 | `
|
421 | : ""}
|
422 | </div>
|
423 |
|
424 | ${!showSetupLinks
|
425 | ? html`
|
426 | <div slot="actions">
|
427 | <ew-text-button
|
428 | @click=${() => {
|
429 | this._state = "DASHBOARD";
|
430 | }}
|
431 | >
|
432 | Continue
|
433 | </ew-text-button>
|
434 | </div>
|
435 | `
|
436 | : ""}
|
437 | `;
|
438 | } else {
|
439 | let error: string | undefined;
|
440 |
|
441 | switch (this._client!.error) {
|
442 | case ImprovSerialErrorState.UNABLE_TO_CONNECT:
|
443 | error = "Unable to connect";
|
444 | break;
|
445 |
|
446 | case ImprovSerialErrorState.TIMEOUT:
|
447 | error = "Timeout";
|
448 | break;
|
449 |
|
450 | case ImprovSerialErrorState.NO_ERROR:
|
451 | // Happens when list SSIDs not supported.
|
452 | case ImprovSerialErrorState.UNKNOWN_RPC_COMMAND:
|
453 | break;
|
454 |
|
455 | default:
|
456 | error = `Unknown error (${this._client!.error})`;
|
457 | }
|
458 | const selectedSsid = this._ssids?.find(
|
459 | (info) => info.name === this._selectedSsid,
|
460 | );
|
461 | content = html`
|
462 | <ew-icon-button slot="headline" @click=${this._updateSsids}>
|
463 | ${refreshIcon}
|
464 | </ew-icon-button>
|
465 | <div slot="content">
|
466 | <div>Connect your device to the network to start using it.</div>
|
467 | ${error ? html`<p class="error">${error}</p>` : ""}
|
468 | ${this._ssids !== null
|
469 | ? html`
|
470 | <ew-filled-select
|
471 | menu-positioning="fixed"
|
472 | label="Network"
|
473 | @change=${(ev: { target: EwFilledSelect }) => {
|
474 | const index = ev.target.selectedIndex;
|
475 | // The "Join Other" item is always the last item.
|
476 | this._selectedSsid =
|
477 | index === this._ssids!.length
|
478 | ? null
|
479 | : this._ssids![index].name;
|
480 | }}
|
481 | >
|
482 | ${this._ssids!.map(
|
483 | (info) => html`
|
484 | <ew-select-option
|
485 | .selected=${selectedSsid === info}
|
486 | .value=${info.name}
|
487 | >
|
488 | ${info.name}
|
489 | </ew-select-option>
|
490 | `,
|
491 | )}
|
492 | <ew-divider></ew-divider>
|
493 | <ew-select-option .selected=${!selectedSsid}>
|
494 | Join other…
|
495 | </ew-select-option>
|
496 | </ew-filled-select>
|
497 | `
|
498 | : ""}
|
499 | ${
|
500 | // Show input box if command not supported or "Join Other" selected
|
501 | !selectedSsid
|
502 | ? html`
|
503 | <ew-filled-text-field
|
504 | label="Network Name"
|
505 | name="ssid"
|
506 | ></ew-filled-text-field>
|
507 | `
|
508 | : ""
|
509 | }
|
510 | ${!selectedSsid || selectedSsid.secured
|
511 | ? html`
|
512 | <ew-filled-text-field
|
513 | label="Password"
|
514 | name="password"
|
515 | type="password"
|
516 | ></ew-filled-text-field>
|
517 | `
|
518 | : ""}
|
519 | </div>
|
520 | <div slot="actions">
|
521 | <ew-text-button
|
522 | @click=${() => {
|
523 | this._state = "DASHBOARD";
|
524 | }}
|
525 | >
|
526 | ${this._installState && this._installErase ? "Skip" : "Back"}
|
527 | </ew-text-button>
|
528 | <ew-text-button @click=${this._doProvision}>Connect</ew-text-button>
|
529 | </div>
|
530 | `;
|
531 | }
|
532 | return [heading, content];
|
533 | }
|
534 |
|
535 | _renderAskErase(): [string | undefined, TemplateResult] {
|
536 | const heading = "Erase device";
|
537 | const content = html`
|
538 | <div slot="content">
|
539 | <div>
|
540 | Do you want to erase the device before installing
|
541 | ${this._manifest.name}? All data on the device will be lost.
|
542 | </div>
|
543 | <label class="formfield">
|
544 | <ew-checkbox touch-target="wrapper" class="danger"></ew-checkbox>
|
545 | Erase device
|
546 | </label>
|
547 | </div>
|
548 | <div slot="actions">
|
549 | <ew-text-button
|
550 | @click=${() => {
|
551 | this._state = "DASHBOARD";
|
552 | }}
|
553 | >
|
554 | Back
|
555 | </ew-text-button>
|
556 | <ew-text-button
|
557 | @click=${() => {
|
558 | const checkbox = this.shadowRoot!.querySelector("ew-checkbox")!;
|
559 | this._startInstall(checkbox.checked);
|
560 | }}
|
561 | >
|
562 | Next
|
563 | </ew-text-button>
|
564 | </div>
|
565 | `;
|
566 |
|
567 | return [heading, content];
|
568 | }
|
569 |
|
570 | _renderInstall(): [string | undefined, TemplateResult, boolean] {
|
571 | let heading: string | undefined;
|
572 | let content: TemplateResult;
|
573 | const allowClosing = false;
|
574 |
|
575 | const isUpdate = !this._installErase && this._isSameFirmware;
|
576 |
|
577 | if (!this._installConfirmed && this._isSameVersion) {
|
578 | heading = "Erase User Data";
|
579 | content = html`
|
580 | <div slot="content">
|
581 | Do you want to reset your device and erase all user data from your
|
582 | device?
|
583 | </div>
|
584 | <div slot="actions">
|
585 | <ew-text-button class="danger" @click=${this._confirmInstall}>
|
586 | Erase User Data
|
587 | </ew-text-button>
|
588 | </div>
|
589 | `;
|
590 | } else if (!this._installConfirmed) {
|
591 | heading = "Confirm Installation";
|
592 | const action = isUpdate ? "update to" : "install";
|
593 | content = html`
|
594 | <div slot="content">
|
595 | ${isUpdate
|
596 | ? html`Your device is running
|
597 | ${this._info!.firmware} ${this._info!.version}.<br /><br />`
|
598 | : ""}
|
599 | Do you want to ${action}
|
600 | ${this._manifest.name} ${this._manifest.version}?
|
601 | ${this._installErase
|
602 | ? html`<br /><br />All data on the device will be erased.`
|
603 | : ""}
|
604 | </div>
|
605 | <div slot="actions">
|
606 | <ew-text-button
|
607 | @click=${() => {
|
608 | this._state = "DASHBOARD";
|
609 | }}
|
610 | >
|
611 | Back
|
612 | </ew-text-button>
|
613 | <ew-text-button @click=${this._confirmInstall}>
|
614 | Install
|
615 | </ew-text-button>
|
616 | </div>
|
617 | `;
|
618 | } else if (
|
619 | !this._installState ||
|
620 | this._installState.state === FlashStateType.INITIALIZING ||
|
621 | this._installState.state === FlashStateType.PREPARING
|
622 | ) {
|
623 | heading = "Installing";
|
624 | content = this._renderProgress("Preparing installation");
|
625 | } else if (this._installState.state === FlashStateType.ERASING) {
|
626 | heading = "Installing";
|
627 | content = this._renderProgress("Erasing");
|
628 | } else if (
|
629 | this._installState.state === FlashStateType.WRITING ||
|
630 | // When we're finished, keep showing this screen with 100% written
|
631 | // until Improv is initialized / not detected.
|
632 | (this._installState.state === FlashStateType.FINISHED &&
|
633 | this._client === undefined)
|
634 | ) {
|
635 | heading = "Installing";
|
636 | let percentage: number | undefined;
|
637 | let undeterminateLabel: string | undefined;
|
638 | if (this._installState.state === FlashStateType.FINISHED) {
|
639 | // We're done writing and detecting improv, show spinner
|
640 | undeterminateLabel = "Wrapping up";
|
641 | } else if (this._installState.details.percentage < 4) {
|
642 | // We're writing the firmware under 4%, show spinner or else we don't show any pixels
|
643 | undeterminateLabel = "Installing";
|
644 | } else {
|
645 | // We're writing the firmware over 4%, show progress bar
|
646 | percentage = this._installState.details.percentage;
|
647 | }
|
648 | content = this._renderProgress(
|
649 | html`
|
650 | ${undeterminateLabel ? html`${undeterminateLabel}<br />` : ""}
|
651 | <br />
|
652 | This will take
|
653 | ${this._installState.chipFamily === "ESP8266"
|
654 | ? "a minute"
|
655 | : "2 minutes"}.<br />
|
656 | Keep this page visible to prevent slow down
|
657 | `,
|
658 | percentage,
|
659 | );
|
660 | } else if (this._installState.state === FlashStateType.FINISHED) {
|
661 | heading = undefined;
|
662 | const supportsImprov = this._client !== null;
|
663 | content = html`
|
664 | <ewt-page-message
|
665 | slot="content"
|
666 | .icon=${OK_ICON}
|
667 | label="Installation complete!"
|
668 | ></ewt-page-message>
|
669 |
|
670 | <div slot="actions">
|
671 | <ew-text-button
|
672 | @click=${() => {
|
673 | this._state =
|
674 | supportsImprov && this._installErase
|
675 | ? "PROVISION"
|
676 | : "DASHBOARD";
|
677 | }}
|
678 | >
|
679 | Next
|
680 | </ew-text-button>
|
681 | </div>
|
682 | `;
|
683 | } else if (this._installState.state === FlashStateType.ERROR) {
|
684 | heading = "Installation failed";
|
685 | content = html`
|
686 | <ewt-page-message
|
687 | slot="content"
|
688 | .icon=${ERROR_ICON}
|
689 | .label=${this._installState.message}
|
690 | ></ewt-page-message>
|
691 | <div slot="actions">
|
692 | <ew-text-button
|
693 | @click=${async () => {
|
694 | this._initialize();
|
695 | this._state = "DASHBOARD";
|
696 | }}
|
697 | >
|
698 | Back
|
699 | </ew-text-button>
|
700 | </div>
|
701 | `;
|
702 | }
|
703 | return [heading, content!, allowClosing];
|
704 | }
|
705 |
|
706 | _renderLogs(): [string | undefined, TemplateResult] {
|
707 | let heading: string | undefined = `Logs`;
|
708 | let content: TemplateResult;
|
709 |
|
710 | content = html`
|
711 | <div slot="content">
|
712 | <ewt-console .port=${this.port} .logger=${this.logger}></ewt-console>
|
713 | </div>
|
714 | <div slot="actions">
|
715 | <ew-text-button
|
716 | @click=${async () => {
|
717 | await this.shadowRoot!.querySelector("ewt-console")!.reset();
|
718 | }}
|
719 | >
|
720 | Reset Device
|
721 | </ew-text-button>
|
722 | <ew-text-button
|
723 | @click=${() => {
|
724 | textDownload(
|
725 | this.shadowRoot!.querySelector("ewt-console")!.logs(),
|
726 | `esp-web-tools-logs.txt`,
|
727 | );
|
728 |
|
729 | this.shadowRoot!.querySelector("ewt-console")!.reset();
|
730 | }}
|
731 | >
|
732 | Download Logs
|
733 | </ew-text-button>
|
734 | <ew-text-button
|
735 | @click=${async () => {
|
736 | await this.shadowRoot!.querySelector("ewt-console")!.disconnect();
|
737 | this._state = "DASHBOARD";
|
738 | this._initialize();
|
739 | }}
|
740 | >
|
741 | Back
|
742 | </ew-text-button>
|
743 | </div>
|
744 | `;
|
745 |
|
746 | return [heading, content!];
|
747 | }
|
748 |
|
749 | public override willUpdate(changedProps: PropertyValues) {
|
750 | if (!changedProps.has("_state")) {
|
751 | return;
|
752 | }
|
753 | // Clear errors when changing between pages unless we change
|
754 | // to the error page.
|
755 | if (this._state !== "ERROR") {
|
756 | this._error = undefined;
|
757 | }
|
758 | // Scan for SSIDs on provision
|
759 | if (this._state === "PROVISION") {
|
760 | this._updateSsids();
|
761 | } else {
|
762 | // Reset this value if we leave provisioning.
|
763 | this._provisionForce = false;
|
764 | }
|
765 |
|
766 | if (this._state === "INSTALL") {
|
767 | this._installConfirmed = false;
|
768 | this._installState = undefined;
|
769 | }
|
770 | }
|
771 |
|
772 | private async _updateSsids(tries = 0) {
|
773 | const oldSsids = this._ssids;
|
774 | this._ssids = undefined;
|
775 | this._busy = true;
|
776 |
|
777 | let ssids: Ssid[];
|
778 |
|
779 | try {
|
780 | ssids = await this._client!.scan();
|
781 | } catch (err) {
|
782 | // When we fail while loading, pick "Join other"
|
783 | if (this._ssids === undefined) {
|
784 | this._ssids = null;
|
785 | this._selectedSsid = null;
|
786 | }
|
787 | this._busy = false;
|
788 | return;
|
789 | }
|
790 |
|
791 | // We will retry a few times if we don't get any results
|
792 | if (ssids.length === 0 && tries < 3) {
|
793 | console.log("SCHEDULE RETRY", tries);
|
794 | setTimeout(() => this._updateSsids(tries + 1), 1000);
|
795 | return;
|
796 | }
|
797 |
|
798 | if (oldSsids) {
|
799 | // If we had a previous list, ensure the selection is still valid
|
800 | if (
|
801 | this._selectedSsid &&
|
802 | !ssids.find((s) => s.name === this._selectedSsid)
|
803 | ) {
|
804 | this._selectedSsid = ssids[0].name;
|
805 | }
|
806 | } else {
|
807 | this._selectedSsid = ssids.length ? ssids[0].name : null;
|
808 | }
|
809 |
|
810 | this._ssids = ssids;
|
811 | this._busy = false;
|
812 | }
|
813 |
|
814 | protected override firstUpdated(changedProps: PropertyValues) {
|
815 | super.firstUpdated(changedProps);
|
816 | this._bodyOverflow = document.body.style.overflow;
|
817 | document.body.style.overflow = "hidden";
|
818 | this._initialize();
|
819 | }
|
820 |
|
821 | protected override updated(changedProps: PropertyValues) {
|
822 | super.updated(changedProps);
|
823 |
|
824 | if (changedProps.has("_state")) {
|
825 | this.setAttribute("state", this._state);
|
826 | }
|
827 |
|
828 | if (this._state !== "PROVISION") {
|
829 | return;
|
830 | }
|
831 |
|
832 | if (changedProps.has("_selectedSsid") && this._selectedSsid === null) {
|
833 | // If we pick "Join other", select SSID input.
|
834 | this._focusFormElement("ew-filled-text-field[name=ssid]");
|
835 | } else if (changedProps.has("_ssids")) {
|
836 | // Form is shown when SSIDs are loaded/marked not supported
|
837 | this._focusFormElement();
|
838 | }
|
839 | }
|
840 |
|
841 | private _focusFormElement(
|
842 | selector = "ew-filled-text-field, ew-filled-select",
|
843 | ) {
|
844 | const formEl = this.shadowRoot!.querySelector(
|
845 | selector,
|
846 | ) as LitElement | null;
|
847 | if (formEl) {
|
848 | formEl.updateComplete.then(() => setTimeout(() => formEl.focus(), 100));
|
849 | }
|
850 | }
|
851 |
|
852 | private async _initialize(justInstalled = false) {
|
853 | if (this.port.readable === null || this.port.writable === null) {
|
854 | this._state = "ERROR";
|
855 | this._error =
|
856 | "Serial port is not readable/writable. Close any other application using it and try again.";
|
857 | return;
|
858 | }
|
859 |
|
860 | try {
|
861 | this._manifest = await downloadManifest(this.manifestPath);
|
862 | } catch (err: any) {
|
863 | this._state = "ERROR";
|
864 | this._error = "Failed to download manifest";
|
865 | return;
|
866 | }
|
867 |
|
868 | if (this._manifest.new_install_improv_wait_time === 0) {
|
869 | this._client = null;
|
870 | return;
|
871 | }
|
872 |
|
873 | const client = new ImprovSerial(this.port!, this.logger);
|
874 | client.addEventListener("state-changed", () => {
|
875 | this.requestUpdate();
|
876 | });
|
877 | client.addEventListener("error-changed", () => this.requestUpdate());
|
878 | try {
|
879 | // If a device was just installed, give new firmware 10 seconds (overridable) to
|
880 | // format the rest of the flash and do other stuff.
|
881 | const timeout = !justInstalled
|
882 | ? 1000
|
883 | : this._manifest.new_install_improv_wait_time !== undefined
|
884 | ? this._manifest.new_install_improv_wait_time * 1000
|
885 | : 10000;
|
886 | this._info = await client.initialize(timeout);
|
887 | this._client = client;
|
888 | client.addEventListener("disconnect", this._handleDisconnect);
|
889 | } catch (err: any) {
|
890 | // Clear old value
|
891 | this._info = undefined;
|
892 | if (err instanceof PortNotReady) {
|
893 | this._state = "ERROR";
|
894 | this._error =
|
895 | "Serial port is not ready. Close any other application using it and try again.";
|
896 | } else {
|
897 | this._client = null; // not supported
|
898 | this.logger.error("Improv initialization failed.", err);
|
899 | }
|
900 | }
|
901 | }
|
902 |
|
903 | private _startInstall(erase: boolean) {
|
904 | this._state = "INSTALL";
|
905 | this._installErase = erase;
|
906 | this._installConfirmed = false;
|
907 | }
|
908 |
|
909 | private async _confirmInstall() {
|
910 | this._installConfirmed = true;
|
911 | this._installState = undefined;
|
912 | if (this._client) {
|
913 | await this._closeClientWithoutEvents(this._client);
|
914 | }
|
915 | this._client = undefined;
|
916 |
|
917 | // Close port. ESPLoader likes opening it.
|
918 | await this.port.close();
|
919 | flash(
|
920 | (state) => {
|
921 | this._installState = state;
|
922 |
|
923 | if (state.state === FlashStateType.FINISHED) {
|
924 | sleep(100)
|
925 | // Flashing closes the port
|
926 | .then(() => this.port.open({ baudRate: 115200 }))
|
927 | .then(() => this._initialize(true))
|
928 | .then(() => this.requestUpdate());
|
929 | } else if (state.state === FlashStateType.ERROR) {
|
930 | sleep(100)
|
931 | // Flashing closes the port
|
932 | .then(() => this.port.open({ baudRate: 115200 }));
|
933 | }
|
934 | },
|
935 | this.port,
|
936 | this.manifestPath,
|
937 | this._manifest,
|
938 | this._installErase,
|
939 | );
|
940 | }
|
941 |
|
942 | private async _doProvision() {
|
943 | this._busy = true;
|
944 | this._wasProvisioned =
|
945 | this._client!.state === ImprovSerialCurrentState.PROVISIONED;
|
946 | const ssid =
|
947 | this._selectedSsid === null
|
948 | ? (
|
949 | this.shadowRoot!.querySelector(
|
950 | "ew-filled-text-field[name=ssid]",
|
951 | ) as EwFilledTextField
|
952 | ).value
|
953 | : this._selectedSsid;
|
954 | const password =
|
955 | (
|
956 | this.shadowRoot!.querySelector(
|
957 | "ew-filled-text-field[name=password]",
|
958 | ) as EwFilledTextField | null
|
959 | )?.value || "";
|
960 | try {
|
961 | await this._client!.provision(ssid, password, 30000);
|
962 | } catch (err: any) {
|
963 | return;
|
964 | } finally {
|
965 | this._busy = false;
|
966 | this._provisionForce = false;
|
967 | }
|
968 | }
|
969 |
|
970 | private _handleDisconnect = () => {
|
971 | this._state = "ERROR";
|
972 | this._error = "Disconnected";
|
973 | };
|
974 |
|
975 | private _closeDialog() {
|
976 | this.shadowRoot!.querySelector("ew-dialog")!.close();
|
977 | }
|
978 |
|
979 | private async _handleClose() {
|
980 | if (this._client) {
|
981 | await this._closeClientWithoutEvents(this._client);
|
982 | }
|
983 | fireEvent(this, "closed" as any);
|
984 | document.body.style.overflow = this._bodyOverflow!;
|
985 | this.parentNode!.removeChild(this);
|
986 | }
|
987 |
|
988 | /**
|
989 | * Return if the device runs same firmware as manifest.
|
990 | */
|
991 | private get _isSameFirmware() {
|
992 | return !this._info
|
993 | ? false
|
994 | : this.overrides?.checkSameFirmware
|
995 | ? this.overrides.checkSameFirmware(this._manifest, this._info)
|
996 | : this._info.firmware === this._manifest.name;
|
997 | }
|
998 |
|
999 | /**
|
1000 | * Return if the device runs same firmware and version as manifest.
|
1001 | */
|
1002 | private get _isSameVersion() {
|
1003 | return (
|
1004 | this._isSameFirmware && this._info!.version === this._manifest.version
|
1005 | );
|
1006 | }
|
1007 |
|
1008 | private async _closeClientWithoutEvents(client: ImprovSerial) {
|
1009 | client.removeEventListener("disconnect", this._handleDisconnect);
|
1010 | await client.close();
|
1011 | }
|
1012 |
|
1013 | private _preventDefault(ev: Event) {
|
1014 | ev.preventDefault();
|
1015 | }
|
1016 |
|
1017 | static styles = [
|
1018 | dialogStyles,
|
1019 | css`
|
1020 | :host {
|
1021 | --mdc-dialog-max-width: 390px;
|
1022 | }
|
1023 | div[slot="headline"] {
|
1024 | padding-right: 48px;
|
1025 | }
|
1026 | ew-icon-button[slot="headline"] {
|
1027 | position: absolute;
|
1028 | right: 4px;
|
1029 | top: 8px;
|
1030 | }
|
1031 | ew-icon-button[slot="headline"] svg {
|
1032 | padding: 8px;
|
1033 | color: var(--text-color);
|
1034 | }
|
1035 | .dialog-nav svg {
|
1036 | color: var(--text-color);
|
1037 | }
|
1038 | .table-row {
|
1039 | display: flex;
|
1040 | }
|
1041 | .table-row.last {
|
1042 | margin-bottom: 16px;
|
1043 | }
|
1044 | .table-row svg {
|
1045 | width: 20px;
|
1046 | margin-right: 8px;
|
1047 | }
|
1048 | ew-filled-text-field,
|
1049 | ew-filled-select {
|
1050 | display: block;
|
1051 | margin-top: 16px;
|
1052 | }
|
1053 | label.formfield {
|
1054 | display: inline-flex;
|
1055 | align-items: center;
|
1056 | padding-right: 8px;
|
1057 | }
|
1058 | ew-list {
|
1059 | margin: 0 -24px;
|
1060 | padding: 0;
|
1061 | }
|
1062 | ew-list-item svg {
|
1063 | height: 24px;
|
1064 | }
|
1065 | ewt-page-message + ew-list {
|
1066 | padding-top: 16px;
|
1067 | }
|
1068 | .fake-icon {
|
1069 | width: 24px;
|
1070 | }
|
1071 | .error {
|
1072 | color: var(--danger-color);
|
1073 | }
|
1074 | .danger {
|
1075 | --mdc-theme-primary: var(--danger-color);
|
1076 | --mdc-theme-secondary: var(--danger-color);
|
1077 | --md-sys-color-primary: var(--danger-color);
|
1078 | --md-sys-color-on-surface: var(--danger-color);
|
1079 | }
|
1080 | button.link {
|
1081 | background: none;
|
1082 | color: inherit;
|
1083 | border: none;
|
1084 | padding: 0;
|
1085 | font: inherit;
|
1086 | text-align: left;
|
1087 | text-decoration: underline;
|
1088 | cursor: pointer;
|
1089 | }
|
1090 | :host([state="LOGS"]) ew-dialog {
|
1091 | max-width: 90vw;
|
1092 | max-height: 90vh;
|
1093 | }
|
1094 | ewt-console {
|
1095 | width: calc(80vw - 48px);
|
1096 | height: calc(90vh - 168px);
|
1097 | }
|
1098 | `,
|
1099 | ];
|
1100 | }
|
1101 |
|
1102 | customElements.define("ewt-install-dialog", EwtInstallDialog);
|
1103 |
|
1104 | declare global {
|
1105 | interface HTMLElementTagNameMap {
|
1106 | "ewt-install-dialog": EwtInstallDialog;
|
1107 | }
|
1108 | }
|
1109 |
|
\ | No newline at end of file |