From b8b44d62fdfd6a690885bdd6ad7b76a97764b2ff Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Fri, 11 Jul 2025 18:09:10 +0800 Subject: wip --- index.html | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 147 insertions(+), 24 deletions(-) (limited to 'index.html') 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. +

+
+ +
+
+
+ -- cgit v1.2.3 From 6f15fb63da85f2d9d651c02f16dabd7fea33935c Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Fri, 11 Jul 2025 21:59:49 +0800 Subject: retry option --- app.css | 14 ++++++++++++++ index.html | 4 ++++ main.mjs | 43 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 2 deletions(-) (limited to 'index.html') diff --git a/app.css b/app.css index 905eedf..d55b049 100644 --- a/app.css +++ b/app.css @@ -168,6 +168,12 @@ body { 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; } @@ -317,3 +323,11 @@ body { background-repeat: no-repeat; background-position: center; } + +.retry-message { + display: none; +} + +.retry-message--shown { + display: block; +} diff --git a/index.html b/index.html index 46b069a..86e3b8b 100644 --- a/index.html +++ b/index.html @@ -104,6 +104,10 @@

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

+

+ 👀 The list has been dismissed. + Try again? +

diff --git a/main.mjs b/main.mjs index d02dba3..f1fb638 100644 --- a/main.mjs +++ b/main.mjs @@ -306,12 +306,31 @@ class CommunicationForm extends Form { #failureForm = null #wifiCredentials = null + #retryMessage = null + #retryMessageClassName = 'retry-message' + #retryMessageShownClassName = 'retry-message--shown' + + #retryLink = null + #retryLinkClassName = `retry-link` + constructor(id, device) { super(id) this.#device = device + 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() + }) + } } - async onDisplay() { + onDisplay() { + this.startPairing() + } + + async startPairing() { if (this.#device === null) { return } @@ -320,7 +339,11 @@ class CommunicationForm extends Form { return } - await this.connect(this.#device, this.wifiCredentials) + try { + await this.connect(this.#device, this.wifiCredentials) + } catch { + this.showRetryOption() + } } async connect() { @@ -353,6 +376,22 @@ class CommunicationForm extends Form { console.log(`Received data from device: ${receivedBytes.join(' ')}`) } + 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 -- cgit v1.2.3 From 9f80e84fa0c82cf8b7a683393051318b4923b372 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 16:48:26 +0800 Subject: communicate progress --- app.css | 40 ++++++++++++++++++++++ index.html | 1 + main.mjs | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 149 insertions(+), 3 deletions(-) (limited to 'index.html') diff --git a/app.css b/app.css index d55b049..aa6c1ec 100644 --- a/app.css +++ b/app.css @@ -324,6 +324,46 @@ body { 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; } diff --git a/index.html b/index.html index 86e3b8b..4371271 100644 --- a/index.html +++ b/index.html @@ -108,6 +108,7 @@ 👀 The list has been dismissed. Try again?

+
    diff --git a/main.mjs b/main.mjs index 5c74731..9d0d025 100644 --- a/main.mjs +++ b/main.mjs @@ -563,6 +563,9 @@ class CommunicationForm extends Form { /** @type {string} */ #descriptionText + /** @type {Progress} */ + #progress + #handshakeCommand #wifiCredentialsCommand #identityCommand @@ -590,6 +593,7 @@ class CommunicationForm extends Form { this.#messages = new IncomingMessageHandler() this.#messages.onMessage(this.handleMessage.bind(this)) this.#setupCounter() + this.#setupProgress() this.#handshakeCommand = new HandshakeCommand() this.#wifiCredentialsCommand = new ConfigureWifiCommand('', '') this.#identityCommand = new RequestIdentityCommand() @@ -611,25 +615,40 @@ class CommunicationForm extends Form { this.#description = this.form.querySelector('.form__description') this.#descriptionText = this.#description ? this.#description.textContent : '' this.#countdown = new Countdown(this.#description, 10) - this.#countdown.onComplete(this.transitToFailureResultForm.bind(this)) + 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: 'identity', name: 'Receive the device\'s identity' } + ]) + } + async startPairing() { try { await this.connect() } catch { this.showRetryOption() - } finally { - this.disconnectIfNeeded() } } async connect() { await this.#handler.connect() + this.#countdown.start() + this.#progress.render() + + this.#progress.markAsCurrent('handshake') await this.#handler.dispatch(this.#handshakeCommand) } @@ -661,6 +680,8 @@ class CommunicationForm extends Form { * @param {CompleteMessage} message */ handleHandshakeMessage(message) { + this.#progress.markAsComplete('handshake') + this.#progress.markAsCurrent('wifi') this.#handler.dispatch(this.#wifiCredentialsCommand) } @@ -668,6 +689,8 @@ class CommunicationForm extends Form { * @param {CompleteMessage} message */ handleWifiCredentialsMessage(message) { + this.#progress.markAsComplete('wifi') + this.#progress.markAsCurrent('identity') this.#handler.dispatch(this.#identityCommand) } @@ -676,6 +699,9 @@ class CommunicationForm extends Form { */ handleIdentityMessage(message) { this.#countdown.stop() + this.#progress.markAsComplete('identity') + this.#progress.clear() + this.disconnectIfNeeded() this.transitToSuccessResultForm() } @@ -733,6 +759,85 @@ class FailureForm extends ProgressibleForm { } } +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 + } +} + class Application { static supportBluetoothApi() { return 'bluetooth' in navigator -- cgit v1.2.3 From 63b4be6c53d3b79e7d9dc24c61cf83649175b265 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 18:10:51 +0800 Subject: fetch device key --- index.html | 5 +-- main.mjs | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 5 deletions(-) (limited to 'index.html') diff --git a/index.html b/index.html index 4371271..1676422 100644 --- a/index.html +++ b/index.html @@ -123,10 +123,7 @@

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

    -
    - - -
    +
    diff --git a/main.mjs b/main.mjs index 9d0d025..4bc1ce5 100644 --- a/main.mjs +++ b/main.mjs @@ -580,6 +580,13 @@ class CommunicationForm extends Form { #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. @@ -702,6 +709,16 @@ class CommunicationForm extends Form { this.#progress.markAsComplete('identity') this.#progress.clear() this.disconnectIfNeeded() + + if (this.#pairHandler) { + const length = message.payload[0] + let deviceId = 0 + for (let i = 0; i < length; i++) { + deviceId = (deviceId << 8) | message.payload[1 + i] + } + this.#pairHandler(deviceId) + } + this.transitToSuccessResultForm() } @@ -736,6 +753,14 @@ class CommunicationForm extends Form { return this } + /** + * @param {CallableFunction} handler + */ + onPair(handler) { + this.#pairHandler = handler + return this + } + transitToSuccessResultForm() { this.hide() this.#successForm?.display() @@ -747,6 +772,126 @@ class CommunicationForm extends Form { } } +class SuccessForm extends Form { + /** @type {HTMLElement} */ + #inputGroup + + /** @type {HTMLElement} */ + #input + + /** @type {HTMLElement} */ + #button + + /** @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.#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 + } + + const response = await fetch(`https://i.airmx.cn/exchange?device=${this.#deviceId}`) + + 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('Copied to the clipboard.') + } catch { + this.#alert('Unable to copy to the clipboard because of permission issues.') + } + } + + /** + * @param {string} message + */ + #alert(message) { + this.form.querySelector('.help-text')?.remove() + + const element = document.createElement('div') + element.classList.add('help-text') + element.textContent = message + + this.form.appendChild(element) + } + + #handleDeviceKeyRetrievalFailure() { + this.#input.value = 'Could not retrieve the device key.' + } + + /** + * @param {number} deviceId + */ + deviceIdUsing(deviceId) { + this.#deviceId = deviceId + return this + } +} + class FailureForm extends ProgressibleForm { constructor(id) { super(id) @@ -852,11 +997,14 @@ class Application { const handler = new BluetoothHandler(Device.airmxPro()) - const successForm = new Form('form-result-success') + const successForm = new SuccessForm('form-result-success') const failureForm = new FailureForm('form-result-failure') 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 wifiCredentialsForm = new WifiCredentialsForm('form-wifi-credentials') -- cgit v1.2.3 From a7c267c39bfd53d3b2e991fc531dc273ac725c2b Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 18:16:40 +0800 Subject: doctype --- app.css | 1 + index.html | 1 + 2 files changed, 2 insertions(+) (limited to 'index.html') diff --git a/app.css b/app.css index aa6c1ec..8d4c683 100644 --- a/app.css +++ b/app.css @@ -92,6 +92,7 @@ body { 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; } @media (min-width: 768px) { diff --git a/index.html b/index.html index 1676422..2f6c920 100644 --- a/index.html +++ b/index.html @@ -1,3 +1,4 @@ + Setup AIRMX device -- cgit v1.2.3 From 5f61c89768024f13b609d2b9e195b00224f46896 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sun, 13 Jul 2025 16:52:06 +0800 Subject: alert component --- index.html | 3 +++ main.mjs | 59 +++++++++++++++++++++++++++++++++-------------------------- 2 files changed, 36 insertions(+), 26 deletions(-) (limited to 'index.html') diff --git a/index.html b/index.html index 2f6c920..0f229ff 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,8 @@ +

    +