install-dialog.ts 30 KB

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