diff options
| -rw-r--r-- | .editorconfig | 8 | ||||
| -rw-r--r-- | app.css | 384 | ||||
| -rw-r--r-- | assets/airmx-pro.png | bin | 0 -> 440464 bytes | |||
| -rw-r--r-- | assets/indicator-failure.png | bin | 0 -> 50988 bytes | |||
| -rw-r--r-- | assets/indicator-success.png | bin | 0 -> 56245 bytes | |||
| -rw-r--r-- | index.html | 200 | ||||
| -rw-r--r-- | main.mjs | 1135 |
7 files changed, 1550 insertions, 177 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ee51031 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true @@ -0,0 +1,384 @@ +/** + * CSS Reset + */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +@media (prefers-reduced-motion: no-preference) { + html { + interpolate-size: allow-keywords; + } +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +p { + text-wrap: pretty; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + text-wrap: balance; +} + +fieldset { + padding: 0; + border: 0; +} + +/** + * Application + */ + +:root { + --color-white: #fff; + --color-zinc-50: #fafafa; + --color-zinc-100: #f4f4f5; + --color-zinc-200: #e4e4e7; + --color-zinc-300: #d4d4d8; + --color-zinc-400: #9f9fa9; + --color-zinc-500: #71717b; + --color-zinc-600: #52525c; + --color-zinc-700: #3f3f46; + --color-zinc-800: #27272a; + --color-zinc-900: #18181b; + --color-zinc-950: #09090b; +} + +body { + display: grid; + grid: max-content max-content 10rem / auto; + justify-content: center; + font-family: "Inter", sans-serif; + color: var(--color-white); + background: var(--color-zinc-950) url(assets/airmx-pro.png) no-repeat center bottom / auto 10rem; + min-height: 100vh; +} + +.page__header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-block-start: 2rem; +} + +.container { + position: relative; + margin-block-start: 3rem; + margin-inline: 1.25rem; + grid-row: 2; + padding: 2rem 1.5rem 3rem; + background: linear-gradient(324deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.06)); + border-radius: 0.375rem; +} + +.form { + display: none; +} + +.form--active { + display: block; +} + +.form__header { + margin-block-end: 3rem; + text-align: center; +} + +.form__indicator { + display: block; + margin-inline: auto; + margin-block-end: 1.5rem; + width: 6rem; + height: 6rem; +} + +.form__title { + font-weight: 500; + font-size: 1.5rem; + line-height: 1.2; + color: var(--color-white); +} + +.form__title--gradient { + background-image: linear-gradient(var(--color-zinc-50), var(--color-zinc-400)); + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.form__description { + margin-block-start: 1rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--color-zinc-500); +} + +.form__text { + font-size: 0.9375rem; + line-height: 1.5; + color: var(--color-zinc-200); +} + +.form__text+.form__text { + margin-block-start: 1.5rem; +} + +.form__link { + font-size: 0.9375rem; + line-height: 1.5; + color: var(--color-zinc-50); +} + +.form__footer { + margin-block-start: 4rem; +} + +.button { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: .625rem; + padding: 0 1.25rem; + font-size: .9375rem; + line-height: 3.25rem; + color: var(--color-zinc-50); + background-color: transparent; + border: 0; + border-radius: .25rem; + width: 100%; + height: 3.25rem; + cursor: pointer; + transition: 150ms linear background-color; +} + +.button:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.button__icon { + transition: 150ms linear transform; +} + +.button:hover .button__icon { + transform: translateX(0.5rem); +} + +.input { + display: block; + padding: 0.5rem 1rem; + font-size: 0.9375rem; + line-height: 2.25rem; + color: var(--color-zinc-500); + background-color: var(--color-zinc-900); + border: 1px solid var(--color-zinc-800); + border-radius: 0.25rem; + width: 100%; + height: 2.25rem; + transition: 50ms linear border-color, 50ms linear outline; +} + +.input:focus { + border-color: transparent; + outline: 1px solid var(--color-zinc-500); +} + +.form__text+.input { + margin-block-start: 2rem; +} + +.input+.input { + margin-block-start: 1.5rem; +} + +.input-group { + display: flex; + flex-direction: row; + align-items: center; + margin-block-start: 2rem; + padding-inline-end: 0.5rem; + background-color: var(--color-zinc-900); + border: 1px solid var(--color-zinc-800); + border-radius: 0.25rem; + height: 3rem; +} + +.input-group .input { + flex: 1; + background-color: transparent; + border: 0; + outline: none; +} + +.input-group .inout:focus { + outline: 0; +} + +.input-group .button { + flex: 0; + padding-inline: 1.5rem; + font-weight: 500; + font-size: 0.875rem; + line-height: 2.25rem; + background-color: var(--color-zinc-700); + border-radius: 0.5rem; + height: 2.25rem; +} + +.input-group .button:hover { + background-color: var(--color-zinc-800); +} + +.help-text { + margin-block-start: 1rem; + font-size: 0.875rem; + color: var(--color-zinc-600); +} + +.physical-button { + margin-inline: 0.5rem; + vertical-align: middle; +} + +.physical-button { + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + background: linear-gradient(0deg, rgba(24, 24, 27, 0.3), rgba(24, 24, 27, 0.3)), linear-gradient(135.86deg, rgba(212, 212, 216, 0.32) 5.24%, rgba(24, 24, 27, 0.2) 51.01%); + border-radius: 100%; +} + +.physical-button__ai { + display: block; + width: 10px; + height: 8px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='8' fill='none'%3E%3Cpath fill='%2308A61E' d='M1.995 7.503H.645L3.103.52h1.56l2.462 6.982h-1.35L3.91 1.953h-.054l-1.861 5.55Zm.044-2.738H5.72v1.016H2.039V4.765ZM9.309.521v6.982H8.046V.52H9.31Z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; +} + +.physical-button__fan { + display: block; + width: 11px; + height: 11px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='11' fill='none'%3E%3Cpath fill='%2317A33C' d='M6.894 7.245c.566.66.528 1.645-.087 2.26l-.367.367a1.471 1.471 0 0 1-2.08-2.08l.552-.554c.313-.312.488-.735.488-1.176v-.56l1.494 1.743Z'/%3E%3Cpath fill='%2317A33C' d='M7.143 4.009a1.665 1.665 0 0 1 2.26.087l.366.366a1.471 1.471 0 0 1-2.08 2.08l-.553-.552a1.665 1.665 0 0 0-1.177-.488H5.4L3.658 6.996a1.665 1.665 0 0 1-2.26-.087l-.367-.366a1.471 1.471 0 0 1 2.08-2.08l.553.552c.312.312.736.487 1.178.487H5.4L7.143 4.01Z'/%3E%3Cpath fill='%2317A33C' d='M4.36 1.133a1.471 1.471 0 0 1 2.08 2.08l-.552.554A1.666 1.666 0 0 0 5.4 4.944v.558L3.906 3.76a1.665 1.665 0 0 1 .087-2.26l.367-.367Z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; +} + +.physical-button__b-circle { + display: block; + width: 15px; + height: 15px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15' fill='none'%3E%3Ccircle cx='8' cy='7.502' r='5.4' stroke='%2317A33C' stroke-linecap='round' stroke-linejoin='round' stroke-width='.9'/%3E%3Cpath stroke='%2317A33C' stroke-linecap='round' stroke-linejoin='round' stroke-width='.9' d='M6.5 7.502v-2.4h2.203c1.315 0 1.5 1.955.273 2.4m-2.476 0v2.4h2.203c1.315 0 1.5-1.954.273-2.4m-2.476 0h2.476'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; +} + +.progress { + margin-block-start: 2rem; + padding-inline: 3rem 1rem; +} + +.progress__item { + position: relative; + font-size: 0.875rem; + line-height: 2.25rem; + color: var(--color-zinc-700); + list-style: none; +} + +.progress__item::before { + position: absolute; + display: block; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none'%3E%3Crect width='16' height='16' y='.002' fill='%233F3F46' rx='8'/%3E%3C/svg%3E"); + width: 1rem; + height: 1rem; + left: -2rem; + top: 0.625rem; + content: ''; +} + +.progress__item[data-current] { + color: var(--color-zinc-300); +} + +.progress__item[data-current]::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none'%3E%3Crect width='16' height='16' y='.002' fill='%233F3F46' rx='8'/%3E%3Crect width='6.4' height='6.4' x='4.8' y='4.802' fill='%239F9FA9' rx='3.2'/%3E%3C/svg%3E"); +} + +.progress__item[data-complete] { + color: var(--color-zinc-300); +} + +.progress__item[data-complete]::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none'%3E%3Crect width='16' height='16' y='.002' fill='%2314532D' rx='8'/%3E%3Cpath fill='%2386EFAC' d='m6.873 9.231-1.485-1.48-.638.635 2.123 2.116 4.377-4.364-.638-.636-3.74 3.729Z'/%3E%3C/svg%3E"); +} + +.retry-message { + display: none; +} + +.retry-message--shown { + display: block; +} + +@media (min-width: 768px) { + body { + grid-template-columns: 40rem; + } + + .page__header { + margin-block-start: 4.5rem; + } + + .container { + padding: 3rem 4rem 4rem; + margin-inline: 0; + } +} diff --git a/assets/airmx-pro.png b/assets/airmx-pro.png Binary files differnew file mode 100644 index 0000000..a07ec56 --- /dev/null +++ b/assets/airmx-pro.png diff --git a/assets/indicator-failure.png b/assets/indicator-failure.png Binary files differnew file mode 100644 index 0000000..c01efd0 --- /dev/null +++ b/assets/indicator-failure.png diff --git a/assets/indicator-success.png b/assets/indicator-success.png Binary files differnew file mode 100644 index 0000000..1bd449f --- /dev/null +++ b/assets/indicator-success.png @@ -1,26 +1,182 @@ +<!DOCTYPE html> <html> - <head> - <title>Setup AIRMX device</title> - <script defer src="main.mjs"></script> - </head> - <body> - <p id="unsupported-message" style="display: none;"> - Your browser does not support the Web Bluetooth API. Please switch to Google Chrome and try again. +<head> + <title>Setup AIRMX device</title> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <script defer src="main.mjs"></script> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet"> + <link href="app.css" rel="stylesheet"> +</head> +<body> + <header class="page__header"> + <svg xmlns="http://www.w3.org/2000/svg" width="158" height="28" fill="none"> + <path fill="#fff" d="m134.916 20-2.395-4.258h-.085l-1.704-1.734v-.049L127.078 8h2.973l2.423 4.478h.076l1.732 1.498v.048L138.003 20h-3.087Zm-7.913 0 3.729-5.976v-.048l1.704-1.498h.085L134.935 8h2.992l-3.645 5.96v.048l-1.732 1.734h-.076L130.06 20h-3.057ZM113.998 20V8h1.745l3.218 4.844h.078L122.257 8h1.745v12h-2.506v-4.958l.039-2.23h-.087l-2.087 3.264h-.722l-2.087-3.265h-.087l.039 2.231V20h-2.506ZM103.058 15.653V13.73h2.346c.405 0 .765-.076 1.079-.228.321-.152.573-.366.756-.643.183-.277.275-.597.275-.96 0-.565-.21-1.007-.628-1.327-.412-.32-.939-.48-1.58-.48h-2.248V8h2.66c.831 0 1.583.141 2.257.423.68.283 1.22.692 1.619 1.23.399.537.599 1.194.599 1.97 0 .754-.21 1.435-.628 2.043-.412.608-1.004 1.091-1.776 1.45-.772.357-1.688.537-2.748.537h-1.983ZM100.998 20V8h2.708v12h-2.708Zm6.938 0-2.895-4.681 2.021-1.384L110.998 20h-3.062ZM92.001 20V8h2.993v12h-2.993Zm-3.003 0v-2.035h9V20h-9Zm0-9.965V8h9v2.035h-9ZM74.998 20l4.15-12h2.698l4.152 12h-2.784l-.985-3.216-.21-.887-1.483-5.088h-.077l-1.482 5.088-.21.887L77.78 20h-2.783Zm2.582-2.605v-1.962h5.835v1.962H77.58ZM56.498 20V8h1.905l4.242 6.334.733 1.197h.078l-.078-2.402V8h2.62v12h-1.916l-4.222-6.407-.733-1.246h-.059l.049 2.508V20h-2.62ZM44.998 20.002v-12h8.5v2.093h-5.654v2.808h4.731v2.036h-4.73v2.971h5.653v2.092h-8.5ZM35.152 15.859v-2.076h2.088a2.38 2.38 0 0 0 1.087-.236 1.792 1.792 0 0 0 1.001-1.637c0-.559-.2-1.001-.6-1.327-.395-.325-.925-.488-1.593-.488h-1.983V8.002h2.164c.954 0 1.78.158 2.48.473.698.314 1.239.76 1.62 1.335.388.575.582 1.25.582 2.027 0 .792-.194 1.492-.582 2.1-.388.603-.931 1.075-1.63 1.417-.693.336-1.497.505-2.412.505h-2.222Zm-2.154 4.143v-12h2.707v12h-2.707ZM24.988 20c-1.058 0-1.96-.236-2.708-.71-.742-.472-1.309-1.154-1.702-2.047-.387-.898-.58-1.981-.58-3.251 0-1.27.193-2.35.58-3.243.393-.892.96-1.572 1.702-2.04.741-.473 1.644-.709 2.708-.709 1.05 0 1.95.236 2.698.71.748.467 1.319 1.15 1.712 2.047.4.892.6 1.97.6 3.235 0 1.27-.197 2.353-.59 3.251-.394.893-.964 1.575-1.712 2.048-.748.473-1.65.709-2.708.709Zm0-2.08c.522 0 .96-.154 1.315-.462.355-.308.622-.754.803-1.338.187-.585.28-1.294.28-2.128-.006-.834-.103-1.54-.29-2.12-.187-.584-.46-1.027-.822-1.33-.355-.308-.783-.462-1.286-.462-.51 0-.941.156-1.296.47-.348.308-.616.754-.803 1.338-.187.58-.28 1.28-.28 2.104 0 .823.093 1.527.28 2.112.194.584.468 1.033.822 1.346.355.314.78.47 1.277.47Z"/> + </svg> + </header> + <main class="container"> + <form id="form-unsupported" class="form"> + <p class="form__text"> + Oops! Your browser doesn't support the Web Bluetooth API. To set up + your AIRMX Pro, please consider switching to Google Chrome 56+ or + Microsoft Edge 79+. + </p> + </form> + + <form id="form-welcome" class="form"> + <header class="form__header"> + <h1 class="form__title form__title--gradient"> + Setup AIRMX Pro + </h1> + <p class="form__description"> + Connect your AIRMX Pro to the internet + </p> + </header> + + <p class="form__text"> + As you may know, the AIRMX servers are no longer available. During + setup, the device will send a registration request to the + manufacturer's API endpoint to verify its identity. You must set up a + mock API server to complete the process. Click here for instructions. + </p> + + <footer class="form__footer"> + <button type="submit" class="button"> + GET STARTED + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fafafa" class="button__icon"> + <path d="M11.131 8.75H2v-1.5h9.131l-4.2-4.2L8 2l6 6-6 6-1.069-1.05 4.2-4.2Z" /> + </svg> + </button> + </footer> + </form> + + <form id="form-wifi-credentials" class="form"> + <header class="form__header"> + <h1 class="form__title"> + Configure Wi-Fi Credentials + </h1> + <p class="form__description"> + Step 1 of 3 </p> + </header> + + <p class="form__text"> + First, connect the machine to the internet using the Wi-Fi + credentials. Note that the device may only support a 2.4 GHz network. + </p> + + <input type="text" id="ssid" class="input" placeholder="SSID" /> + <input type="password" id="password" class="input" placeholder="Password" /> + + <p data-alert></p> + + <footer class="form__footer"> + <button type="submit" class="button"> + NEXT + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fafafa" class="button__icon"> + <path d="M11.131 8.75H2v-1.5h9.131l-4.2-4.2L8 2l6 6-6 6-1.069-1.05 4.2-4.2Z" /> + </svg> + </button> + </footer> + </form> + + <form id="form-pairing-activation" class="form"> + <header class="form__header"> + <h1 class="form__title"> + Activate Paring Mode + </h1> + <p class="form__description"> + Step 2 of 3 + </p> + </header> + + <p class="form__text"> + We're almost there! To activate pairing mode, press and hold the AI button + <span class="physical-button"><span class="physical-button__ai"></span></span> + and the fan speed increase button + <span class="physical-button"><span class="physical-button__fan"></span></span> + simultaneously for 3 seconds. + </p> + + <p class="form__text"> + Release all the buttons once the green Bluetooth icon + <span class="physical-button"><span class="physical-button__b-circle"></span></span> + starts flashing. + </p> + + <footer class="form__footer"> + <button type="submit" class="button"> + I'M READY + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fafafa" class="button__icon"> + <path d="M11.131 8.75H2v-1.5h9.131l-4.2-4.2L8 2l6 6-6 6-1.069-1.05 4.2-4.2Z" /> + </svg> + </button> + </footer> + </form> + + <form id="form-communication" class="form"> + <header class="form__header"> + <h1 class="form__title"> + Communicating with Device + </h1> + <p class="form__description"> + Step 3 of 3 + </p> + </header> + <p class="form__text"> + One more thing, let's choose the AIRMX Pro device from the Bluetooth + scanning list. + </p> + <p class="form__text retry-message"> + 👀 The list has been dismissed. + <a href="javascript:void(0)" class="form__link retry-link">Try again?</a> + </p> + <ol data-slot="progress"></ol> + </form> + + <form id="form-result-success" class="form"> + <header class="form__header"> + <img src="assets/indicator-success.png" class="form__indicator" /> + <h1 class="form__title"> + Congratulations! Your device has been successfully set up. + </h1> + </header> + <p class="form__text"> + Here is your device key. Please copy and save it in a safe place. + You may need to integrate your device with another platform to control + or monitor it remotely. + </p> + <p class="form__text"> + If you lose it, don't worry. You can reset and re-pair your device + on this page at any time. + </p> + <div class="input-group" data-key></div> + <p data-alert></p> + </form> - <main> - <h1>Setup AIRMX Pro</h1> - <div> - <input type="text" id="ssid" placeholder="SSID" /> - </div> - <div> - <input type="password" id="password" placeholder="Password" /> - </div> - <div> - <button id="connect" type="button" onclick="connect()"> - Connect - </button> - </div> - </main> - </body> + <form id="form-result-failure" class="form"> + <header class="form__header"> + <img src="assets/indicator-failure.png" class="form__indicator" /> + <h1 class="form__title"> + Oops! Something went wrong. + </h1> + </header> + <p class="form__text"> + Time's up. The device didn't respond. Here are some tips: Please + confirm that the Wi-Fi credentials are correct and that the access + point is a 2.4 GHz network. Also, check to see if the mock server + is up and running. + </p> + <footer class="form__footer"> + <button type="submit" class="button"> + RETRY + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fafafa" class="button__icon"> + <path d="M11.131 8.75H2v-1.5h9.131l-4.2-4.2L8 2l6 6-6 6-1.069-1.05 4.2-4.2Z" /> + </svg> + </button> + </footer> + </form> + </main> +</body> </html> @@ -1,229 +1,1054 @@ class Device { - #deviceName - #primaryServiceUuid - #writeCharacteristicUuid - #notifyCharacteristicUuid - - constructor(deviceName, primaryServiceUuid, writeCharacteristicUuid, notifyCharacteristicUuid) { - this.#deviceName = deviceName - this.#primaryServiceUuid = primaryServiceUuid - this.#writeCharacteristicUuid = writeCharacteristicUuid - this.#notifyCharacteristicUuid = notifyCharacteristicUuid - } - - static airmxPro() { - return new Device( - 'AIRMX Pro', - '22210000-554a-4546-5542-46534450464d', - '22210001-554a-4546-5542-46534450464d', - '22210002-554a-4546-5542-46534450464d' + #deviceName + #primaryServiceUuid + #writeCharacteristicUuid + #notifyCharacteristicUuid + + constructor(deviceName, primaryServiceUuid, writeCharacteristicUuid, notifyCharacteristicUuid) { + this.#deviceName = deviceName + this.#primaryServiceUuid = primaryServiceUuid + this.#writeCharacteristicUuid = writeCharacteristicUuid + this.#notifyCharacteristicUuid = notifyCharacteristicUuid + } + + static airmxPro() { + return new Device( + 'AIRMX Pro', + '22210000-554a-4546-5542-46534450464d', + '22210001-554a-4546-5542-46534450464d', + '22210002-554a-4546-5542-46534450464d' + ) + } + + get name() { + return this.#deviceName + } + + get primaryServiceUuid() { + return this.#primaryServiceUuid + } + + get writeCharacteristicUuid() { + return this.#writeCharacteristicUuid + } + + get notifyCharacteristicUuid() { + return this.#notifyCharacteristicUuid + } +} + +class Dispatcher { + #characteristic + #sequenceNumber = 1 + #chunkSize = 16 + + constructor(characteristic) { + this.#characteristic = characteristic + } + + async dispatch(command) { + const data = this.#chunk(command.payload, command) + + for (let chunk of data) { + console.log(`Sending chunk: ${Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' ')}`) + await this.#characteristic.writeValueWithResponse(chunk) + await Clock.delay(500) + } + } + + #chunk(data, command) { + const packets = this.#chunked(data, this.#chunkSize) + const total = packets.length + + if (total === 0) { + return [ + this.#packetHeader( + this.#sequenceNumber++, 1, 1, // 1 of 1 packet + command.commandId ) + ] } - get name() { - return this.#deviceName + return packets.map((chunk, index) => { + return new Uint8Array([ + ...this.#packetHeader( + this.#sequenceNumber++, index + 1, total, + command.commandId + ), + ...chunk + ]) + }) + } + + /** + * Splits an array into chunks of `size`. + * + * @param {Uint8Array} data - The array of 8-bit unsigned integers. + * @param {number} size - The size of each chunk. + */ + #chunked(data, size) { + const packets = [] + + for (let i = 0; i < data.length; i += size) { + packets.push(data.slice(i, i + size)) } - get primaryServiceUuid() { - return this.#primaryServiceUuid + return packets + } + + #packetHeader(sequenceNumber, currentPacket, totalPacket, commandId) { + return new Uint8Array([ + sequenceNumber, + currentPacket << 4 | totalPacket, + 0x00, // Unencrypted flag + commandId + ]) + } +} + +class BluetoothHandler { + #device + #dispatcher = null + #gatt = null + #writeCharacteristic = null + #notifyCharacteristic = null + #notificationHandler = null + + /** + * @param {Device} device + */ + constructor(device) { + this.#device = device + } + + async connect() { + const device = await navigator.bluetooth.requestDevice({ + filters: [{ name: this.#device.name }], + optionalServices: [this.#device.primaryServiceUuid] + }) + + this.#gatt = await device.gatt.connect() + const service = await this.#gatt.getPrimaryService(this.#device.primaryServiceUuid) + this.#writeCharacteristic = await service.getCharacteristic(this.#device.writeCharacteristicUuid) + this.#notifyCharacteristic = await service.getCharacteristic(this.#device.notifyCharacteristicUuid) + this.#dispatcher = new Dispatcher(this.#writeCharacteristic) + + await this.#notifyCharacteristic.startNotifications() + this.#notifyCharacteristic.addEventListener('characteristicvaluechanged', this.#handleNotification.bind(this)) + } + + async disconnect() { + if (this.#notifyCharacteristic) { + await this.#notifyCharacteristic.stopNotifications() + this.#notifyCharacteristic.removeEventListener('characteristicvaluechanged', this.#handleNotification.bind(this)) + this.#notifyCharacteristic = null + } + + this.#dispatcher = null + this.#writeCharacteristic = null + + if (this.#gatt) { + this.#gatt.disconnect() + this.#gatt = null } + } - get writeCharacteristicUuid() { - return this.#writeCharacteristicUuid + /** + * @param {Command} command - The command to dispatch. + */ + async dispatch(command) { + if (! this.#dispatcher) { + return } + await this.#dispatcher.dispatch(command) + return this + } + + onNotification(handler) { + this.#notificationHandler = handler + return this + } - get notifyCharacteristicUuid() { - return this.#notifyCharacteristicUuid + #handleNotification(event) { + if (this.#notificationHandler) { + this.#notificationHandler(event) } + } } -class Dispatcher { - #characteristic - #sequenceNumber = 1 - #chunkSize = 16 +class Command { + get commandId() { + throw new Error('The command ID does not exist.') + } + + get payload() { + throw new Error('The payload does not exist.') + } +} + +class HandshakeCommand extends Command { + get commandId() { + return 0x0b + } + + get payload() { + return new Uint8Array([ + 0x08, // The storage size of the token + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Token: 0 + 0x05, // The length of the version + 0x31, 0x2e, 0x30, 0x2e, 0x30 // Version: 1.0.0 + ]) + } +} + +class ConfigureWifiCommand extends Command { + #ssid + #password + + constructor(ssid, password) { + super() + this.#ssid = ssid + this.#password = password + } + + get commandId() { + return 0x15 + } + + get payload() { + const encoder = new TextEncoder() + const ssid = encoder.encode(this.#ssid) + const password = encoder.encode(this.#password) + + return new Uint8Array([ + ssid.length, ...ssid, + password.length, ...password + ]) + } +} + +class RegisterCommand extends Command { + get commandId() { + return 0x16 + } + + get payload() { + return new Uint8Array([ + // + ]) + } +} - constructor(characteristic) { - this.#characteristic = characteristic +class Clock { + static delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } +} + +class IncomingMessageHandler { + /** @type {IncomingMessage[]} */ + #bag = [] + + #messageHandler = null + + /** + * @param {DataView} view + */ + handle(view) { + const message = IncomingMessage.parse(view) + this.#addToBag(message) + } + + onMessage(handler) { + this.#messageHandler = handler + return this + } + + /** + * @param {IncomingMessage} message + */ + #addToBag(message) { + this.#bag.push(message) + + if (message.currentPacket === message.totalPacket) { + this.#processBag() } + } - async dispatch(command) { - const data = this.#chunk(command.payload, command) + #processBag() { + const bag = [...this.#bag] + const lastMessage = bag.at(-1) - for (let chunk of data) { - console.log(`Sending chunk: ${Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' ')}`) - await this.#characteristic.writeValueWithResponse(chunk) - await delay(500) - } + if (bag.length !== lastMessage.totalPacket) { + throw new Error('Incomplete message received.') } - #chunk(data, command) { - const packets = this.#chunked(data, this.#chunkSize) - const total = packets.length + let data = new Uint8Array() - if (total === 0) { - return [ - this.#packetHeader( - this.#sequenceNumber++, 1, 1, // 1 of 1 packet - command.commandId - ) - ] - } + for (const [index, message] of bag.entries()) { + if (message.currentPacket !== index + 1) { + throw new Error(`Message packet ${index + 1} is missing.`) + } - return packets.map((chunk, index) => { - return new Uint8Array([ - ...this.#packetHeader( - this.#sequenceNumber++, index + 1, total, - command.commandId - ), - ...chunk - ]) - }) + const temp = new Uint8Array(data.byteLength + message.payload.byteLength) + temp.set(data) + temp.set(message.payload, data.byteLength) + data = temp } - /** - * Splits an array into chunks of `size`. - * - * @param {Uint8Array} data - The array of 8-bit unsigned integers. - * @param {number} size - The size of each chunk. - */ - #chunked(data, size) { - const packets = [] + const completeMessage = new CompleteMessage( + lastMessage.commandId, new DataView(data.buffer) + ) + + this.#notify(completeMessage) + this.#clearBag() + } - for (let i = 0; i < data.length; i += size) { - packets.push(data.slice(i, i + size)) - } + #clearBag() { + this.#bag = [] + } - return packets + /** + * @param {CompleteMessage} message + */ + #notify(message) { + if (this.#messageHandler) { + this.#messageHandler(message) } + } +} + +class IncomingMessage { + /** + * @param {number} sequenceNumber - The sequence number of the packet. + * @param {number} currentPacket - The current packet number. + * @param {number} totalPacket - The total number of packets. + * @param {boolean} encrypted - Determines if the packet is encrypted. + * @param {number} commandId - The command ID. + * @param {Uint8Array} payload - The message payload. + */ + constructor(sequenceNumber, currentPacket, totalPacket, encrypted, commandId, payload) { + this.sequenceNumber = sequenceNumber + this.currentPacket = currentPacket + this.totalPacket = totalPacket + this.encrypted = encrypted + this.commandId = commandId + this.payload = payload + } - #packetHeader(sequenceNumber, currentPacket, totalPacket, commandId) { - return new Uint8Array([ - sequenceNumber, - currentPacket << 4 | totalPacket, - 0x00, // Unencrypted flag - commandId - ]) + /** + * @param {DataView} view - The raw packet data. + * @returns {IncomingMessage} + */ + static parse(view) { + if (view.byteLength < 4) { + throw new Error('Invalid packet length.') } + + const sequenceNumber = view.getUint8(0) + const currentPacket = view.getUint8(1) >> 4 + const totalPacket = view.getUint8(1) & 0x0f + const encrypted = view.getUint8(2) + const commandId = view.getUint8(3) + + return new IncomingMessage( + sequenceNumber, currentPacket, totalPacket, + !! encrypted, commandId, new Uint8Array(view.buffer.slice(4)) + ) + } } -class Command { - get commandId() { - throw new Error('The command ID does not exist.') +class CompleteMessage { + /** + * @param {number} commandId - The command ID of the message. + * @param {DataView} payload - The message payload. + */ + constructor(commandId, payload) { + this.commandId = commandId + this.payload = payload + } +} + +class Countdown { + #clock = null + #stopHandler = null + #completeHandler = null + + /** + * @param {HTMLElement|null} el - The HTML element to display the countdown. + * @param {number} seconds - The number of seconds for the countdown. + */ + constructor(el, seconds) { + this.el = el + this.seconds = seconds + + if (this.el === null) { + throw new Error('The HTML element could not be found to mount the countdown.') } + } - get payload() { - throw new Error('The payload does not exist.') + start() { + this.stop() + + let remaining = this.seconds + this.#clock = setInterval(() => { + this.el.textContent = `${--remaining}s` + if (remaining === 0) { + this.stop() + this.notifyComplete() + } + }, 1000) + } + + stop() { + if (! this.#clock) { + return } -} -class HandshakeCommand extends Command { - get commandId() { - return 0x0b + clearInterval(this.#clock) + this.#clock = null + + if (this.#stopHandler) { + this.#stopHandler() } + } + + onStop(callback) { + this.#stopHandler = callback + } - get payload() { - return new Uint8Array([ - 0x08, // The storage size of the token - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Token: 0 - 0x05, // The length of the version - 0x31, 0x2e, 0x30, 0x2e, 0x30 // Version: 1.0.0 - ]) + onComplete(callback) { + this.#completeHandler = callback + } + + notifyComplete() { + if (this.#completeHandler) { + this.#completeHandler() } + } } -class ConfigureWifiCommand extends Command { - #ssid - #password +class Form { + #activeClassName = 'form--active' + + constructor(id) { + this.form = document.getElementById(id) + if (this.form === null) { + throw new Error(`Form with id ${id} does not exist.`) + } + } + + display() { + this.form.classList.add(this.#activeClassName) - constructor(ssid, password) { - super() - this.#ssid = ssid - this.#password = password + if (typeof this.onDisplay === 'function') { + this.onDisplay() } + } + + hide() { + this.form.classList.remove(this.#activeClassName) - get commandId() { - return 0x15 + if (typeof this.onHide === 'function') { + this.onHide() } + } +} + +class ProgressibleForm extends Form { + #nextForm = null - get payload() { - const encoder = new TextEncoder() - const ssid = encoder.encode(this.#ssid) - const password = encoder.encode(this.#password) + nextTo(form) { + this.#nextForm = form + return this + } - return new Uint8Array([ - ssid.length, ...ssid, - password.length, ...password - ]) + transitToNextForm() { + if (this.#nextForm === null) { + return } + + this.hide() + this.#nextForm.display() + } +} + +class WelcomeForm extends ProgressibleForm { + constructor(id) { + super(id) + this.form.addEventListener('submit', this.handleSubmit.bind(this)) + } + + handleSubmit(event) { + event.preventDefault() + this.transitToNextForm() + } } -class RequestIdentityCommand extends Command { - get commandId() { - return 0x16 +class WifiCredentialsForm extends ProgressibleForm { + #alert + #submitCallback = null + + constructor(id) { + super(id) + this.#alert = new Alert(this.form.querySelector('[data-alert]')) + this.form.addEventListener('submit', this.handleSubmit.bind(this)) + } + + handleSubmit(event) { + event.preventDefault() + const { elements } = event.target + try { + const [ssid, password] = this.validate(elements.ssid.value, elements.password.value) + if (this.#submitCallback) { + this.#submitCallback({ ssid, password }) + } + this.transitToNextForm() + } catch (error) { + this.#alert.show(error.message) + } + } + + /** + * Validate with the IEEE 802.11 standard. + * + * @param {string} ssid - The Wi-Fi name + * @param {string} password - The Password (WPA/WPA2-PSK) + */ + validate(ssid, password) { + if (ssid === '' ) { + throw new Error('SSID cannot be empty.') + } + + // The SSID is up to 32 characters + if (ssid.length > 32) { + throw new Error('SSID cannot be longer than 32 characters.') } - get payload() { - return new Uint8Array([ - // - ]) + if (password === '' ) { + throw new Error('Password cannot be empty.') } + + // The password is between 8 and 63 characters + if (password.length < 8 || password.length > 63) { + throw new Error('Password must be between 8 and 63 characters long.') + } + + return [ssid, password] + } + + onSubmit(callback) { + this.#submitCallback = callback + return this + } } -async function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) +class PairingActivationForm extends ProgressibleForm { + constructor(id) { + super(id) + this.form.addEventListener('submit', this.handleSubmit.bind(this)) + } + + handleSubmit(event) { + event.preventDefault() + this.transitToNextForm() + } } -async function connect() { - const ssid = document.getElementById('ssid') - const password = document.getElementById('password') +class CommunicationForm extends Form { + #handler + #messages = null - if (ssid.value === '' || password.value === '') { - return + /** @type {Countdown} */ + #countdown + + /** @type {HTMLElement|null} */ + #description + + /** @type {string} */ + #descriptionText + + /** @type {Progress} */ + #progress + + #handshakeCommand + #wifiCredentialsCommand + #registerCommand + + #successForm = null + #failureForm = null + + #retryMessage = null + #retryMessageClassName = 'retry-message' + #retryMessageShownClassName = 'retry-message--shown' + + #retryLink = null + #retryLinkClassName = `retry-link` + + /** + * The callback handles the registered device. + * + * @type {CallableFunction|null} + */ + #pairHandler = null + + /** + * @param {string} id - The ID of the form. + * @param {BluetoothHandler} handler - The Bluetooth handler to manage the connection. + */ + constructor(id, handler) { + super(id) + this.#handler = handler + this.#handler.onNotification((event) => { + this.#messages.handle(event.target.value) + }) + this.#messages = new IncomingMessageHandler() + this.#messages.onMessage(this.handleMessage.bind(this)) + this.#setupCounter() + this.#setupProgress() + this.#handshakeCommand = new HandshakeCommand() + this.#wifiCredentialsCommand = new ConfigureWifiCommand('', '') + this.#registerCommand = new RegisterCommand() + this.#retryMessage = this.form.querySelector(`.${this.#retryMessageClassName}`) + if (this.#retryMessage) { + this.#retryLink = this.form.querySelector(`.${this.#retryLinkClassName}`) + this.#retryLink.addEventListener('click', () => { + this.hideRetryOption() + this.startPairing() + }) } + } + + onDisplay() { + this.startPairing() + } + + #setupCounter() { + this.#description = this.form.querySelector('.form__description') + this.#descriptionText = this.#description ? this.#description.textContent : '' + this.#countdown = new Countdown(this.#description, 30) + this.#countdown.onComplete(() => { + this.#progress.clear() + this.disconnectIfNeeded() + this.transitToFailureResultForm() + }) + this.#countdown.onStop(() => { + this.#description.textContent = this.#descriptionText + }) + } + + #setupProgress() { + const el = this.form.querySelector('[data-slot="progress"]') + this.#progress = new Progress(el, [ + { id: 'handshake', name: 'Say a hello to the machine' }, + { id: 'wifi', name: 'Send Wi-Fi credentials' }, + { id: 'register', name: 'Register the device' } + ]) + } + + async startPairing() { + try { + await this.connect() + } catch { + this.showRetryOption() + } + } + + async connect() { + await this.#handler.connect() + + this.#countdown.start() + this.#progress.render() + + this.#progress.markAsCurrent('handshake') + await this.#handler.dispatch(this.#handshakeCommand) + } + + async disconnectIfNeeded() { + await this.#handler.disconnect() + } + + /** + * @param {CompleteMessage} message + */ + handleMessage(message) { + switch (message.commandId) { + case this.#handshakeCommand.commandId: + this.handleHandshakeMessage(message) + break + case this.#wifiCredentialsCommand.commandId: + this.handleWifiCredentialsMessage(message) + break + case this.#registerCommand.commandId: + this.handleRegisterMessage(message) + break + default: + console.warn(`Unknown command ID: ${message.commandId}`) + break + } + } + + /** + * @param {CompleteMessage} message + */ + handleHandshakeMessage(message) { + this.#progress.markAsComplete('handshake') + this.#progress.markAsCurrent('wifi') + this.#handler.dispatch(this.#wifiCredentialsCommand) + } - const device = Device.airmxPro() - const wifiCredentials = { ssid: ssid.value, password: password.value } - await connectToDevice(device, wifiCredentials) + /** + * @param {CompleteMessage} message + */ + handleWifiCredentialsMessage(message) { + this.#progress.markAsComplete('wifi') + this.#progress.markAsCurrent('register') + this.#handler.dispatch(this.#registerCommand) + } + + /** + * @param {CompleteMessage} message + */ + handleRegisterMessage(message) { + this.#countdown.stop() + this.#progress.markAsComplete('register') + this.#progress.clear() + this.disconnectIfNeeded() + + if (this.#pairHandler) { + const length = message.payload.getUint8(0) + if (length === 4) { + const deviceId = message.payload.getUint32(1) + this.#pairHandler(deviceId) + } + } + + this.transitToSuccessResultForm() + } + + showRetryOption() { + if (this.#retryMessage === null) { + return + } + + this.#retryMessage.classList.add(this.#retryMessageShownClassName) + } + + hideRetryOption() { + if (this.#retryMessage === null) { + return + } + + this.#retryMessage.classList.remove(this.#retryMessageShownClassName) + } + + succeedTo(form) { + this.#successForm = form + return this + } + + failTo(form) { + this.#failureForm = form + return this + } + + wifiCredentialsUsing(credentials) { + this.#wifiCredentialsCommand = new ConfigureWifiCommand(credentials.ssid, credentials.password) + return this + } + + /** + * @param {CallableFunction} handler + */ + onPair(handler) { + this.#pairHandler = handler + return this + } + + transitToSuccessResultForm() { + this.hide() + this.#successForm?.display() + } + + transitToFailureResultForm() { + this.hide() + this.#failureForm?.display() + } } -async function connectToDevice(device, wifiCredentials) { - const bluetoothDevice = await navigator.bluetooth.requestDevice({ - filters: [{ name: device.name }], - optionalServices: [device.primaryServiceUuid] +class SuccessForm extends Form { + /** @type {HTMLElement} */ + #inputGroup + + /** @type {HTMLElement} */ + #input + + /** @type {HTMLElement} */ + #button + + #alert + + /** @type {string|null} */ + #deviceId = null + + /** @type {string|null} */ + #key = null + + constructor(id) { + super(id) + this.#inputGroup = this.form.querySelector('[data-key]') + if (this.#inputGroup === null) { + throw new Error('Could not find the input group for device key.') + } + this.#alert = new Alert(this.form.querySelector('[data-alert]')) + this.#renderInput() + } + + #renderInput() { + this.#input = document.createElement('input') + this.#input.classList.add('input') + this.#input.setAttribute('type', 'text') + this.#input.setAttribute('name', 'key') + this.#input.setAttribute('value', 'Loading...') + this.#input.setAttribute('readonly', '') + this.#inputGroup.appendChild(this.#input) + } + + #renderButton() { + this.#button = document.createElement('button') + this.#button.classList.add('button') + this.#button.setAttribute('type', 'button') + this.#button.addEventListener('click', this.#handleButtonClick.bind(this)) + this.#button.innerText = 'Copy' + this.#inputGroup.appendChild(this.#button) + } + + onDisplay() { + if (this.#key === null) { + this.#initializeDeviceKey() + } + } + + async #initializeDeviceKey() { + try { + this.#key = await this.#fetchDeviceKey() + + if (this.#key === null) { + this.#handleDeviceKeyRetrievalFailure() + return + } + + this.#input.value = this.#key + this.#renderButton() + } catch { + this.#handleDeviceKeyRetrievalFailure() + } + } + + async #fetchDeviceKey() { + if (this.#deviceId === null) { + return null + } + + // We are using HTTP because the domain name is remapped to our local + // mock server, and the communication between the device and our mock + // server utilizes the HTTP protocol as well. + const response = await fetch(`http://i.airmx.cn/exchange?device=${this.#deviceId}`, { + signal: AbortSignal.timeout(3000) }) - const server = await bluetoothDevice.gatt.connect() - const service = await server.getPrimaryService(device.primaryServiceUuid) + if (! response.ok) { + throw new Error('Could not retrieve the device key.') + } + + const data = await response.json() + return data.key + } + + async #handleButtonClick() { + if (! this.#input) { + return + } + + try { + await navigator.clipboard.writeText(this.#input.value) + this.#alert.show('Copied to the clipboard.') + } catch { + this.#alert.show('Unable to copy to the clipboard because of permission issues.') + } + } + + #handleDeviceKeyRetrievalFailure() { + this.#input.value = 'Could not retrieve the device key.' + } - const writeCharacteristic = await service.getCharacteristic(device.writeCharacteristicUuid) - const notifyCharacteristic = await service.getCharacteristic(device.notifyCharacteristicUuid) + /** + * @param {number} deviceId + */ + deviceIdUsing(deviceId) { + this.#deviceId = deviceId + return this + } +} - await notifyCharacteristic.startNotifications() - notifyCharacteristic.addEventListener('characteristicvaluechanged', handleDeviceResponse) +class FailureForm extends ProgressibleForm { + constructor(id) { + super(id) + this.form.addEventListener('submit', this.handleSubmit.bind(this)) + } - const dispatcher = new Dispatcher(writeCharacteristic) - await dispatcher.dispatch(new HandshakeCommand()) - await dispatcher.dispatch(new ConfigureWifiCommand(wifiCredentials.ssid, wifiCredentials.password)) - await dispatcher.dispatch(new RequestIdentityCommand()) + handleSubmit(event) { + event.preventDefault() + this.transitToNextForm() + } } -function handleDeviceResponse(event) { - const value = event.target.value - const receivedBytes = [] - for (let i = 0; i < value.byteLength; i++) { - receivedBytes.push(value.getUint8(i).toString(16).padStart(2, '0')) +class Alert { + /** @type {HTMLElement} */ + #el + + /** + * @param {HTMLElement|null} el - The HTML element to mount the alert component. + */ + constructor(el) { + if (el === null) { + throw new Error('The HTML element to mount the alert component does not exist.') + } + this.#el = el + } + + /** + * @param {string} message + */ + show(message) { + if (! this.#el.classList.contains('help-text')) { + this.#el.classList.add('help-text') } - console.log(`Received data from device: ${receivedBytes.join(' ')}`) + this.#el.textContent = message + } } -function supportBluetoothApi() { - return 'bluetooth' in navigator +class Progress { + #el + #steps + + /** + * @param {HTMLElement|null} el - The HTML element to display the progress. + * @param {{ id: string, name: string }[]} steps - The progress steps. + */ + constructor(el, steps) { + this.#el = el + this.#steps = steps + if (this.#el === null) { + throw new Error('The HTML element could not be found to mount the progress.') + } + } + + render() { + if (! this.#el.classList.contains('progress')) { + this.#el.classList.add('progress') + } + + for (const step of this.#steps) { + const el = document.createElement('li') + el.innerText = step.name + el.classList.add('progress__item') + el.dataset.progress = step.id + this.#el.appendChild(el) + } + } + + clear() { + this.#el.innerHTML = '' + } + + /** + * @param {string} step - The step ID. + */ + markAsCurrent(step) { + this.markAsDefault(step) + + const el = this.#stepElement(step) + el.dataset.current = '' + } + + /** + * @param {string} step - The step ID. + */ + markAsComplete(step) { + this.markAsDefault(step) + + const el = this.#stepElement(step) + el.dataset.complete = '' + } + + /** + * @param {string} step - The step ID. + */ + markAsDefault(step) { + const el = this.#stepElement(step) + if ('current' in el.dataset) { + delete el.dataset.current + } + if ('complete' in el.dataset) { + delete el.dataset.complete + } + } + + /** + * @param {string} id - The step ID. + */ + #stepElement(id) { + const el = this.#el.querySelector(`[data-progress="${id}"]`) + if (! el) { + throw new Error(`Progress step "${step}" does not exist.`) + } + return el + } } -if (! supportBluetoothApi()) { - const unsupportedMessage = document.getElementById('unsupported-message') - unsupportedMessage.style.display = 'block' +class Application { + static supportBluetoothApi() { + return 'bluetooth' in navigator + } + + static run() { + if (! this.supportBluetoothApi()) { + const unsupportedForm = new Form('form-unsupported') + unsupportedForm.display() + return + } + + const successForm = new SuccessForm('form-result-success') + const failureForm = new FailureForm('form-result-failure') + + const handler = new BluetoothHandler(Device.airmxPro()) + const communicationForm = new CommunicationForm('form-communication', handler) + .succeedTo(successForm) + .failTo(failureForm) + .onPair((deviceId) => { + successForm.deviceIdUsing(deviceId) + }) + + const pairingActivationForm = new PairingActivationForm('form-pairing-activation') + .nextTo(communicationForm) - const main = document.querySelector('main') - main.style.display = 'none' + const wifiCredentialsForm = new WifiCredentialsForm('form-wifi-credentials') + .nextTo(pairingActivationForm) + .onSubmit((credentials) => { + communicationForm.wifiCredentialsUsing(credentials) + }) + + // If the pairing process fails, we will redirect the user to the Wi-Fi + // credentials form so that they can retry with different credentials. + failureForm.nextTo(wifiCredentialsForm) + + // Now that everything is set up, it's time to show the user + // the welcome screen. + new WelcomeForm('form-welcome') + .nextTo(wifiCredentialsForm) + .display() + } } + +Application.run() |
