From b8b44d62fdfd6a690885bdd6ad7b76a97764b2ff Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Fri, 11 Jul 2025 18:09:10 +0800 Subject: wip --- .editorconfig | 8 + app.css | 313 +++++++++++++++++++++++++++++ assets/airmx-pro.png | Bin 0 -> 440464 bytes assets/indicator-failure.png | Bin 0 -> 50988 bytes assets/indicator-success.png | Bin 0 -> 56245 bytes index.html | 171 +++++++++++++--- main.mjs | 460 ++++++++++++++++++++++++++----------------- 7 files changed, 751 insertions(+), 201 deletions(-) create mode 100644 .editorconfig create mode 100644 app.css create mode 100644 assets/airmx-pro.png create mode 100644 assets/indicator-failure.png create mode 100644 assets/indicator-success.png 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 diff --git a/app.css b/app.css new file mode 100644 index 0000000..6104a97 --- /dev/null +++ b/app.css @@ -0,0 +1,313 @@ +/** + * 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; + 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; +} + +@media (min-width: 768px) { + body { + grid: max-content max-content 4.5rem / 40rem; + } +} + +.page__header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + margin-block-start: 4.5rem; +} + +.container { + position: relative; + margin-block-start: 3rem; + grid-row: 2; + padding: 3rem 4rem 4rem; + 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(--title-color); +} + +.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__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; + gap: 1rem; + 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); +} + +.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; +} diff --git a/assets/airmx-pro.png b/assets/airmx-pro.png new file mode 100644 index 0000000..a07ec56 Binary files /dev/null and b/assets/airmx-pro.png differ diff --git a/assets/indicator-failure.png b/assets/indicator-failure.png new file mode 100644 index 0000000..c01efd0 Binary files /dev/null and b/assets/indicator-failure.png differ diff --git a/assets/indicator-success.png b/assets/indicator-success.png new file mode 100644 index 0000000..1bd449f Binary files /dev/null and b/assets/indicator-success.png differ diff --git a/index.html b/index.html index b2a6736..46b069a 100644 --- a/index.html +++ b/index.html @@ -1,26 +1,149 @@ - - Setup AIRMX device - - - - - -
-

Setup AIRMX Pro

-
- -
-
- -
-
- -
-
- + + Setup AIRMX device + + + + + + + + +
+
+

+ 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+. +

+
+ +
+
+

Setup AIRMX Pro

+

Connect your AIRMX Pro to the internet

+
+ +

+ 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. +

+ +
+ +
+
+ +
+
+

Configure Wi-Fi Credentials

+

Step 1 of 3

+
+ +

+ First, connect the machine to the internet using the Wi-Fi credentials. Note that the device may only + support a 2.4 GHz network. +

+ + + + +
+ +
+
+ +
+
+

Activate Paring Mode

+

Step 2 of 3

+
+ +

+ We're almost there! To activate pairing mode, press and hold the AI button + + and the fan speed increase button + + simultaneously for 3 seconds. +

+ +

+ Release all the buttons once the green Bluetooth icon + + starts flashing. +

+ +
+ +
+
+ +
+
+

Communicating with Device

+

Step 3 of 3

+
+

+ One more thing, let's choose the AIRMX Pro device from the Bluetooth scanning list. +

+
+ +
+
+ +

Congratulations! Your device has been successfully set up.

+
+

+ 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. +

+

+ If you lose it, don't worry. You can reset and re-pair your device on this page at any time. +

+
+ + +
+
+ +
+
+ +

Oops! Something went wrong.

+
+

+ 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. +

+ +
+
+ diff --git a/main.mjs b/main.mjs index 31d4056..e19400e 100644 --- a/main.mjs +++ b/main.mjs @@ -1,229 +1,335 @@ 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 + } +} - get name() { - return this.#deviceName - } +class Dispatcher { + #characteristic + #sequenceNumber = 1 + #chunkSize = 16 - get primaryServiceUuid() { - return this.#primaryServiceUuid - } + constructor(characteristic) { + this.#characteristic = characteristic + } - get writeCharacteristicUuid() { - return this.#writeCharacteristicUuid - } + async dispatch(command) { + const data = this.#chunk(command.payload, command) - get notifyCharacteristicUuid() { - return this.#notifyCharacteristicUuid + 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) } -} + } -class Dispatcher { - #characteristic - #sequenceNumber = 1 - #chunkSize = 16 + #chunk(data, command) { + const packets = this.#chunked(data, this.#chunkSize) + const total = packets.length - constructor(characteristic) { - this.#characteristic = characteristic + if (total === 0) { + return [ + this.#packetHeader( + this.#sequenceNumber++, 1, 1, // 1 of 1 packet + command.commandId + ) + ] } - 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 delay(500) - } + 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)) } - #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 - ) - ] - } - - return packets.map((chunk, index) => { - return new Uint8Array([ - ...this.#packetHeader( - this.#sequenceNumber++, index + 1, total, - command.commandId - ), - ...chunk - ]) - }) - } + return packets + } + + #packetHeader(sequenceNumber, currentPacket, totalPacket, commandId) { + return new Uint8Array([ + sequenceNumber, + currentPacket << 4 | totalPacket, + 0x00, // Unencrypted flag + commandId + ]) + } +} - /** - * 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 = [] +class Command { + get commandId() { + throw new Error('The command ID does not exist.') + } - for (let i = 0; i < data.length; i += size) { - packets.push(data.slice(i, i + size)) - } + get payload() { + throw new Error('The payload does not exist.') + } +} - return packets - } +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 + ]) + } +} - #packetHeader(sequenceNumber, currentPacket, totalPacket, commandId) { - return new Uint8Array([ - sequenceNumber, - currentPacket << 4 | totalPacket, - 0x00, // Unencrypted flag - commandId - ]) - } +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 Command { - get commandId() { - throw new Error('The command ID does not exist.') - } +class RequestIdentityCommand extends Command { + get commandId() { + return 0x16 + } + + get payload() { + return new Uint8Array([ + // + ]) + } +} - get payload() { - throw new Error('The payload does not exist.') - } +async function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) } -class HandshakeCommand extends Command { - get commandId() { - return 0x0b - } +async function connect() { + const ssid = document.getElementById('ssid') + const password = document.getElementById('password') - 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 - ]) - } + if (ssid.value === '' || password.value === '') { + return + } + + const device = Device.airmxPro() + const wifiCredentials = { ssid: ssid.value, password: password.value } + await connectToDevice(device, wifiCredentials) } -class ConfigureWifiCommand extends Command { - #ssid - #password +async function connectToDevice(device, wifiCredentials) { + const bluetoothDevice = await navigator.bluetooth.requestDevice({ + filters: [{ name: device.name }], + optionalServices: [device.primaryServiceUuid] + }) - constructor(ssid, password) { - super() - this.#ssid = ssid - this.#password = password - } + const server = await bluetoothDevice.gatt.connect() + const service = await server.getPrimaryService(device.primaryServiceUuid) - get commandId() { - return 0x15 - } + const writeCharacteristic = await service.getCharacteristic(device.writeCharacteristicUuid) + const notifyCharacteristic = await service.getCharacteristic(device.notifyCharacteristicUuid) - get payload() { - const encoder = new TextEncoder() - const ssid = encoder.encode(this.#ssid) - const password = encoder.encode(this.#password) + await notifyCharacteristic.startNotifications() + notifyCharacteristic.addEventListener('characteristicvaluechanged', handleDeviceResponse) - return new Uint8Array([ - ssid.length, ...ssid, - password.length, ...password - ]) - } + const dispatcher = new Dispatcher(writeCharacteristic) + await dispatcher.dispatch(new HandshakeCommand()) + await dispatcher.dispatch(new ConfigureWifiCommand(wifiCredentials.ssid, wifiCredentials.password)) + await dispatcher.dispatch(new RequestIdentityCommand()) } -class RequestIdentityCommand extends Command { - get commandId() { - return 0x16 - } +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')) + } + console.log(`Received data from device: ${receivedBytes.join(' ')}`) +} - get payload() { - return new Uint8Array([ - // - ]) +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.`) } -} + } -async function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) + display() { + this.form.classList.add(this.#activeClassName) + } + + hide() { + this.form.classList.remove(this.#activeClassName) + } } -async function connect() { - const ssid = document.getElementById('ssid') - const password = document.getElementById('password') +class ProgressibleForm extends Form { + #nextForm = null - if (ssid.value === '' || password.value === '') { - return + nextTo(form) { + this.#nextForm = form + return this + } + + nextForm() { + if (this.#nextForm === null) { + return } - const device = Device.airmxPro() - const wifiCredentials = { ssid: ssid.value, password: password.value } - await connectToDevice(device, wifiCredentials) + this.hide() + this.#nextForm.display() + } } -async function connectToDevice(device, wifiCredentials) { - const bluetoothDevice = await navigator.bluetooth.requestDevice({ - filters: [{ name: device.name }], - optionalServices: [device.primaryServiceUuid] - }) +class WelcomeForm extends ProgressibleForm { + constructor(id) { + super(id) + this.form.addEventListener('submit', this.handleSubmit.bind(this)) + } + + handleSubmit(event) { + event.preventDefault() + this.nextForm() + } +} - const server = await bluetoothDevice.gatt.connect() - const service = await server.getPrimaryService(device.primaryServiceUuid) +class WifiCredentialsForm extends ProgressibleForm { + constructor(id) { + super(id) + this.form.addEventListener('submit', this.handleSubmit.bind(this)) + } - const writeCharacteristic = await service.getCharacteristic(device.writeCharacteristicUuid) - const notifyCharacteristic = await service.getCharacteristic(device.notifyCharacteristicUuid) + handleSubmit(event) { + event.preventDefault() + this.nextForm() + } +} - await notifyCharacteristic.startNotifications() - notifyCharacteristic.addEventListener('characteristicvaluechanged', handleDeviceResponse) +class PairingActivationForm 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.nextForm() + } } -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')) - } - console.log(`Received data from device: ${receivedBytes.join(' ')}`) +class CommunicationForm extends Form { + #successForm = null + #failureForm = null + + succeedTo(form) { + this.#successForm = form + return this + } + + failTo(form) { + this.#failureForm = form + return this + } } -function supportBluetoothApi() { +class Application { + static supportBluetoothApi() { return 'bluetooth' in navigator -} + } -if (! supportBluetoothApi()) { - const unsupportedMessage = document.getElementById('unsupported-message') - unsupportedMessage.style.display = 'block' + static run() { + if (! this.supportBluetoothApi()) { + const unsupportedForm = new Form('form-unsupported') + unsupportedForm.display() + return + } - const main = document.querySelector('main') - main.style.display = 'none' + const successForm = new Form('form-result-success') + const failureForm = new Form('form-result-failure') + const communicationForm = new CommunicationForm('form-communication') + .succeedTo(successForm) + .failTo(failureForm) + const pairingActivationForm = new PairingActivationForm('form-pairing-activation') + .nextTo(communicationForm) + const wifiCredentialsForm = new WifiCredentialsForm('form-wifi-credentials') + .nextTo(pairingActivationForm) + const welcomeForm = new WelcomeForm('form-welcome') + .nextTo(wifiCredentialsForm) + + welcomeForm.display() + } } + +Application.run() -- cgit v1.2.3