From b8b44d62fdfd6a690885bdd6ad7b76a97764b2ff Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Fri, 11 Jul 2025 18:09:10 +0800 Subject: wip --- main.mjs | 460 +++++++++++++++++++++++++++++++++++++++------------------------ 1 file changed, 283 insertions(+), 177 deletions(-) (limited to 'main.mjs') 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 From 4ba55c31c693b26c630a245b845cd4e85de6b639 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Fri, 11 Jul 2025 21:16:24 +0800 Subject: validate wi-fi credentials --- app.css | 6 ++++++ main.mjs | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) (limited to 'main.mjs') diff --git a/app.css b/app.css index 6104a97..905eedf 100644 --- a/app.css +++ b/app.css @@ -269,6 +269,12 @@ body { background-color: var(--color-zinc-800); } +.help-text { + margin-top: 1rem; + font-size: 0.875rem; + color: var(--color-zinc-600); +} + .physical-button { margin-inline: 0.5rem; vertical-align: middle; diff --git a/main.mjs b/main.mjs index e19400e..56862f4 100644 --- a/main.mjs +++ b/main.mjs @@ -266,6 +266,8 @@ class WelcomeForm extends ProgressibleForm { } class WifiCredentialsForm extends ProgressibleForm { + #submitCallback = null + constructor(id) { super(id) this.form.addEventListener('submit', this.handleSubmit.bind(this)) @@ -273,7 +275,51 @@ class WifiCredentialsForm extends ProgressibleForm { handleSubmit(event) { event.preventDefault() - this.nextForm() + const { elements } = event.target + try { + const [ssid, password] = this.validate(elements.ssid.value, elements.password.value) + if (this.#submitCallback) { + this.#submitCallback({ ssid, password }) + } + this.nextForm() + } catch (error) { + this.alert(error.message) + } + } + + validate(ssid, password) { + if (ssid === '' ) { + throw new Error('SSID cannot be empty.') + } + + if (ssid.length > 32) { + throw new Error('SSID cannot be longer than 32 characters.') + } + + if (password === '' ) { + throw new Error('Password cannot be empty.') + } + + if (password.length < 8 || password.length > 63) { + throw new Error('Password must be between 8 and 63 characters long.') + } + + return [ssid, password] + } + + alert(message) { + this.form.querySelector('.help-text')?.remove() + + const element = document.createElement('div') + element.classList.add('help-text', 'help-text--danger') + element.textContent = message + + this.form.querySelector('.form__footer').before(element) + } + + onSubmit(callback) { + this.#submitCallback = callback + return this } } @@ -292,6 +338,7 @@ class PairingActivationForm extends ProgressibleForm { class CommunicationForm extends Form { #successForm = null #failureForm = null + #wifiCredentials = null succeedTo(form) { this.#successForm = form @@ -302,6 +349,11 @@ class CommunicationForm extends Form { this.#failureForm = form return this } + + wifiCredentialsUsing(credentials) { + this.#wifiCredentials = credentials + return this + } } class Application { @@ -325,6 +377,9 @@ class Application { .nextTo(communicationForm) const wifiCredentialsForm = new WifiCredentialsForm('form-wifi-credentials') .nextTo(pairingActivationForm) + .onSubmit((credentials) => { + communicationForm.wifiCredentialsUsing(credentials) + }) const welcomeForm = new WelcomeForm('form-welcome') .nextTo(wifiCredentialsForm) -- cgit v1.2.3 From 36dc45faca21be3507b2690828d538d7a75ac469 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Fri, 11 Jul 2025 21:24:06 +0800 Subject: rename --- main.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index 56862f4..3c4d6b4 100644 --- a/main.mjs +++ b/main.mjs @@ -243,7 +243,7 @@ class ProgressibleForm extends Form { return this } - nextForm() { + transitToNextForm() { if (this.#nextForm === null) { return } @@ -261,7 +261,7 @@ class WelcomeForm extends ProgressibleForm { handleSubmit(event) { event.preventDefault() - this.nextForm() + this.transitToNextForm() } } @@ -281,7 +281,7 @@ class WifiCredentialsForm extends ProgressibleForm { if (this.#submitCallback) { this.#submitCallback({ ssid, password }) } - this.nextForm() + this.transitToNextForm() } catch (error) { this.alert(error.message) } @@ -331,7 +331,7 @@ class PairingActivationForm extends ProgressibleForm { handleSubmit(event) { event.preventDefault() - this.nextForm() + this.transitToNextForm() } } -- cgit v1.2.3 From 0c115430faf3574ca431d318f139dbf3d48cff6c Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Fri, 11 Jul 2025 21:41:55 +0800 Subject: refactor --- main.mjs | 101 +++++++++++++++++++++++++++++++++++---------------------------- 1 file changed, 57 insertions(+), 44 deletions(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index 3c4d6b4..d02dba3 100644 --- a/main.mjs +++ b/main.mjs @@ -173,49 +173,6 @@ async function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } -async function connect() { - const ssid = document.getElementById('ssid') - const password = document.getElementById('password') - - if (ssid.value === '' || password.value === '') { - return - } - - const device = Device.airmxPro() - const wifiCredentials = { ssid: ssid.value, password: password.value } - await connectToDevice(device, wifiCredentials) -} - -async function connectToDevice(device, wifiCredentials) { - const bluetoothDevice = await navigator.bluetooth.requestDevice({ - filters: [{ name: device.name }], - optionalServices: [device.primaryServiceUuid] - }) - - const server = await bluetoothDevice.gatt.connect() - const service = await server.getPrimaryService(device.primaryServiceUuid) - - const writeCharacteristic = await service.getCharacteristic(device.writeCharacteristicUuid) - const notifyCharacteristic = await service.getCharacteristic(device.notifyCharacteristicUuid) - - await notifyCharacteristic.startNotifications() - notifyCharacteristic.addEventListener('characteristicvaluechanged', handleDeviceResponse) - - const dispatcher = new Dispatcher(writeCharacteristic) - await dispatcher.dispatch(new HandshakeCommand()) - await dispatcher.dispatch(new ConfigureWifiCommand(wifiCredentials.ssid, wifiCredentials.password)) - await dispatcher.dispatch(new RequestIdentityCommand()) -} - -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 Form { #activeClassName = 'form--active' @@ -228,10 +185,18 @@ class Form { display() { this.form.classList.add(this.#activeClassName) + + if (typeof this.onDisplay === 'function') { + this.onDisplay() + } } hide() { this.form.classList.remove(this.#activeClassName) + + if (typeof this.onHide === 'function') { + this.onHide() + } } } @@ -336,10 +301,58 @@ class PairingActivationForm extends ProgressibleForm { } class CommunicationForm extends Form { + #device = null #successForm = null #failureForm = null #wifiCredentials = null + constructor(id, device) { + super(id) + this.#device = device + } + + async onDisplay() { + if (this.#device === null) { + return + } + + if (this.#wifiCredentials === null) { + return + } + + await this.connect(this.#device, this.wifiCredentials) + } + + async connect() { + const device = await navigator.bluetooth.requestDevice({ + filters: [{ name: this.#device.name }], + optionalServices: [this.#device.primaryServiceUuid] + }) + + const server = await device.gatt.connect() + const service = await server.getPrimaryService(device.primaryServiceUuid) + + const writeCharacteristic = await service.getCharacteristic(device.writeCharacteristicUuid) + const notifyCharacteristic = await service.getCharacteristic(device.notifyCharacteristicUuid) + + await notifyCharacteristic.startNotifications() + notifyCharacteristic.addEventListener('characteristicvaluechanged', this.handleDeviceResponse.bind(this)) + + const dispatcher = new Dispatcher(writeCharacteristic) + await dispatcher.dispatch(new HandshakeCommand()) + await dispatcher.dispatch(new ConfigureWifiCommand(this.#wifiCredentials.ssid, this.#wifiCredentials.password)) + await dispatcher.dispatch(new RequestIdentityCommand()) + } + + 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(' ')}`) + } + succeedTo(form) { this.#successForm = form return this @@ -370,7 +383,7 @@ class Application { const successForm = new Form('form-result-success') const failureForm = new Form('form-result-failure') - const communicationForm = new CommunicationForm('form-communication') + const communicationForm = new CommunicationForm('form-communication', Device.airmxPro()) .succeedTo(successForm) .failTo(failureForm) const pairingActivationForm = new PairingActivationForm('form-pairing-activation') -- 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 'main.mjs') 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 aa5f274be5a42df3470a35b6facb3038f4b9bfb6 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 11:42:16 +0800 Subject: parse responses from the device --- main.mjs | 309 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 273 insertions(+), 36 deletions(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index f1fb638..74b8e57 100644 --- a/main.mjs +++ b/main.mjs @@ -106,6 +106,76 @@ class Dispatcher { } } +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 + } + } + + /** + * @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 + } + + #handleNotification(event) { + if (this.#notificationHandler) { + this.#notificationHandler(event) + } + } +} + class Command { get commandId() { throw new Error('The command ID does not exist.') @@ -173,6 +243,127 @@ async function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } +class IncomingMessageHandler { + #encoder + #bag = [] + #messageHandler = null + + constructor() { + this.#encoder = new TextEncoder() + } + + handle(packet) { + const message = IncomingMessage.parse(this.#encoder.encode(packet)) + 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() + } + } + + #processBag() { + const bag = [...this.#bag] + const lastMessage = bag.at(-1) + + if (bag.length !== lastMessage.totalPacket) { + throw new Error('Incomplete message received.') + } + + const data = [] + + for (const [index, message] of bag.entries()) { + if (message.currentPacket !== index + 1) { + throw new Error(`Message packet ${index + 1} is missing.`) + } + + data.push(...message.payload) + } + + const completeMessage = new CompleteMessage( + lastMessage.commandId, new Uint8Array(data) + ) + + this.#notify(completeMessage) + this.#clearBag() + } + + #clearBag() { + this.#bag = [] + } + + /** + * @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 + } + + /** + * @param {Uint8Array} packet - The raw packet data. + * @returns {IncomingMessage} + */ + static parse(packet) { + if (packet.length < 4) { + throw new Error('Invalid packet length.') + } + + const sequenceNumber = packet[0] + const currentPacket = packet[1] >> 4 + const totalPacket = packet[1] & 0x0f + const encrypted = packet[2] + const commandId = packet[3] + + return new IncomingMessage( + sequenceNumber, currentPacket, totalPacket, + !! encrypted, commandId, packet.slice(4) + ) + } +} + +class CompleteMessage { + /** + * @param {number} commandId - The command ID of the message. + * @param {Uint8Array} payload - The message payload. + */ + constructor(commandId, payload) { + this.commandId = commandId + this.payload = payload + } +} + class Form { #activeClassName = 'form--active' @@ -301,10 +492,15 @@ class PairingActivationForm extends ProgressibleForm { } class CommunicationForm extends Form { - #device = null + #handler + #messages = null + + #handshakeCommand + #wifiCredentialsCommand + #identityCommand + #successForm = null #failureForm = null - #wifiCredentials = null #retryMessage = null #retryMessageClassName = 'retry-message' @@ -313,9 +509,21 @@ class CommunicationForm extends Form { #retryLink = null #retryLinkClassName = `retry-link` - constructor(id, device) { + /** + * @param {string} id - The ID of the form. + * @param {BluetoothHandler} handler - The Bluetooth handler to manage the connection. + */ + constructor(id, handler) { super(id) - this.#device = device + 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.#handshakeCommand = new HandshakeCommand() + this.#wifiCredentialsCommand = new ConfigureWifiCommand('', '') + this.#identityCommand = new RequestIdentityCommand() this.#retryMessage = this.form.querySelector(`.${this.#retryMessageClassName}`) if (this.#retryMessage) { this.#retryLink = this.form.querySelector(`.${this.#retryLinkClassName}`) @@ -331,49 +539,66 @@ class CommunicationForm extends Form { } async startPairing() { - if (this.#device === null) { - return - } - - if (this.#wifiCredentials === null) { - return - } - try { - await this.connect(this.#device, this.wifiCredentials) + await this.connect() } catch { this.showRetryOption() + } finally { + this.disconnectIfNeeded() } } async connect() { - const device = await navigator.bluetooth.requestDevice({ - filters: [{ name: this.#device.name }], - optionalServices: [this.#device.primaryServiceUuid] - }) + if (! this.#handler) { + return + } + await this.#handler.connect() + await this.#handler.dispatch(this.#handshakeCommand) + } - const server = await device.gatt.connect() - const service = await server.getPrimaryService(device.primaryServiceUuid) + async disconnectIfNeeded() { + await this.#handler.disconnect() + } - const writeCharacteristic = await service.getCharacteristic(device.writeCharacteristicUuid) - const notifyCharacteristic = await service.getCharacteristic(device.notifyCharacteristicUuid) + /** + * @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.#identityCommand.commandId: + this.handleIdentityMessage(message) + break + default: + console.warn(`Unknown command ID: ${message.commandId}`) + break + } + } - await notifyCharacteristic.startNotifications() - notifyCharacteristic.addEventListener('characteristicvaluechanged', this.handleDeviceResponse.bind(this)) + /** + * @param {CompleteMessage} message + */ + handleHandshakeMessage(message) { + this.#handler.dispatch(this.#wifiCredentialsCommand) + } - const dispatcher = new Dispatcher(writeCharacteristic) - await dispatcher.dispatch(new HandshakeCommand()) - await dispatcher.dispatch(new ConfigureWifiCommand(this.#wifiCredentials.ssid, this.#wifiCredentials.password)) - await dispatcher.dispatch(new RequestIdentityCommand()) + /** + * @param {CompleteMessage} message + */ + handleWifiCredentialsMessage(message) { + this.#handler.dispatch(this.#identityCommand) } - 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(' ')}`) + /** + * @param {CompleteMessage} message + */ + handleIdentityMessage(message) { + this.transitToSuccessResultForm() } showRetryOption() { @@ -403,9 +628,19 @@ class CommunicationForm extends Form { } wifiCredentialsUsing(credentials) { - this.#wifiCredentials = credentials + this.#wifiCredentialsCommand = new ConfigureWifiCommand(credentials.ssid, credentials.password) return this } + + transitToSuccessResultForm() { + this.hide() + this.#successForm?.display() + } + + transitToFailureResultForm() { + this.hide() + this.#failureForm?.display() + } } class Application { @@ -420,9 +655,11 @@ class Application { return } + const handler = new BluetoothHandler(Device.airmxPro()) + const successForm = new Form('form-result-success') const failureForm = new Form('form-result-failure') - const communicationForm = new CommunicationForm('form-communication', Device.airmxPro()) + const communicationForm = new CommunicationForm('form-communication', handler) .succeedTo(successForm) .failTo(failureForm) const pairingActivationForm = new PairingActivationForm('form-pairing-activation') -- cgit v1.2.3 From 69b1017a6cab8327e9321c8af428dc038e3cd6e0 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 15:32:28 +0800 Subject: countdown component --- main.mjs | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index 74b8e57..28e2dd4 100644 --- a/main.mjs +++ b/main.mjs @@ -364,6 +364,65 @@ class CompleteMessage { } } +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.') + } + } + + 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 + } + + clearInterval(this.#clock) + this.#clock = null + + if (this.#stopHandler) { + this.#stopHandler() + } + } + + onStop(callback) { + this.#stopHandler = callback + } + + onComplete(callback) { + this.#completeHandler = callback + } + + notifyComplete() { + if (this.#completeHandler) { + this.#completeHandler() + } + } +} + class Form { #activeClassName = 'form--active' @@ -495,6 +554,15 @@ class CommunicationForm extends Form { #handler #messages = null + /** @type {Countdown} */ + #countdown + + /** @type {HTMLElement|null} */ + #description + + /** @type {string} */ + #descriptionText + #handshakeCommand #wifiCredentialsCommand #identityCommand @@ -521,6 +589,7 @@ class CommunicationForm extends Form { }) this.#messages = new IncomingMessageHandler() this.#messages.onMessage(this.handleMessage.bind(this)) + this.#setupCounter() this.#handshakeCommand = new HandshakeCommand() this.#wifiCredentialsCommand = new ConfigureWifiCommand('', '') this.#identityCommand = new RequestIdentityCommand() @@ -538,6 +607,16 @@ class CommunicationForm extends Form { this.startPairing() } + #setupCounter() { + 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.onStop(() => { + this.#description.textContent = this.#descriptionText + }) + } + async startPairing() { try { await this.connect() @@ -553,6 +632,7 @@ class CommunicationForm extends Form { return } await this.#handler.connect() + this.#countdown.start() await this.#handler.dispatch(this.#handshakeCommand) } @@ -598,6 +678,7 @@ class CommunicationForm extends Form { * @param {CompleteMessage} message */ handleIdentityMessage(message) { + this.#countdown.stop() this.transitToSuccessResultForm() } @@ -643,6 +724,18 @@ class CommunicationForm extends Form { } } +class FailureForm extends ProgressibleForm { + constructor(id) { + super(id) + this.form.addEventListener('submit', this.handleSubmit.bind(this)) + } + + handleSubmit(event) { + event.preventDefault() + this.transitToNextForm() + } +} + class Application { static supportBluetoothApi() { return 'bluetooth' in navigator @@ -658,7 +751,7 @@ class Application { const handler = new BluetoothHandler(Device.airmxPro()) const successForm = new Form('form-result-success') - const failureForm = new Form('form-result-failure') + const failureForm = new FailureForm('form-result-failure') const communicationForm = new CommunicationForm('form-communication', handler) .succeedTo(successForm) .failTo(failureForm) @@ -672,6 +765,12 @@ class Application { const welcomeForm = new WelcomeForm('form-welcome') .nextTo(wifiCredentialsForm) + // 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. welcomeForm.display() } } -- cgit v1.2.3 From 3f07ce0826968c1461e7bf6c3e8072bc18862416 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 15:35:21 +0800 Subject: remove unnecesarilly --- main.mjs | 3 --- 1 file changed, 3 deletions(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index 28e2dd4..5c74731 100644 --- a/main.mjs +++ b/main.mjs @@ -628,9 +628,6 @@ class CommunicationForm extends Form { } async connect() { - if (! this.#handler) { - return - } await this.#handler.connect() this.#countdown.start() await this.#handler.dispatch(this.#handshakeCommand) -- 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 'main.mjs') 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 'main.mjs') 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 45f1d969484000c045eaca352092b9ba64af7521 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 20:24:55 +0800 Subject: reorder code --- main.mjs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index 4bc1ce5..a607769 100644 --- a/main.mjs +++ b/main.mjs @@ -995,25 +995,25 @@ class Application { return } - const handler = new BluetoothHandler(Device.airmxPro()) - 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 wifiCredentialsForm = new WifiCredentialsForm('form-wifi-credentials') .nextTo(pairingActivationForm) .onSubmit((credentials) => { communicationForm.wifiCredentialsUsing(credentials) }) - const welcomeForm = new WelcomeForm('form-welcome') - .nextTo(wifiCredentialsForm) // If the pairing process fails, we will redirect the user to the Wi-Fi // credentials form so that they can retry with different credentials. @@ -1021,7 +1021,9 @@ class Application { // Now that everything is set up, it's time to show the user // the welcome screen. - welcomeForm.display() + new WelcomeForm('form-welcome') + .nextTo(wifiCredentialsForm) + .display() } } -- cgit v1.2.3 From 507315cd43cc58dd7e588d381f584efabe8e4e44 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 22:15:34 +0800 Subject: use DataView --- main.mjs | 48 ++++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index a607769..40023ba 100644 --- a/main.mjs +++ b/main.mjs @@ -245,15 +245,21 @@ async function delay(ms) { class IncomingMessageHandler { #encoder + + /** @type {IncomingMessage[]} */ #bag = [] + #messageHandler = null constructor() { this.#encoder = new TextEncoder() } - handle(packet) { - const message = IncomingMessage.parse(this.#encoder.encode(packet)) + /** + * @param {DataView} view + */ + handle(view) { + const message = IncomingMessage.parse(view) this.#addToBag(message) } @@ -281,18 +287,21 @@ class IncomingMessageHandler { throw new Error('Incomplete message received.') } - const data = [] + let data = new Uint8Array() for (const [index, message] of bag.entries()) { if (message.currentPacket !== index + 1) { throw new Error(`Message packet ${index + 1} is missing.`) } - data.push(...message.payload) + const temp = new Uint8Array(data.byteLength + message.payload.byteLength) + temp.set(data) + temp.set(message.payload, data.byteLength) + data = temp } const completeMessage = new CompleteMessage( - lastMessage.commandId, new Uint8Array(data) + lastMessage.commandId, new DataView(data.buffer) ) this.#notify(completeMessage) @@ -332,23 +341,23 @@ class IncomingMessage { } /** - * @param {Uint8Array} packet - The raw packet data. + * @param {DataView} view - The raw packet data. * @returns {IncomingMessage} */ - static parse(packet) { - if (packet.length < 4) { + static parse(view) { + if (view.byteLength < 4) { throw new Error('Invalid packet length.') } - const sequenceNumber = packet[0] - const currentPacket = packet[1] >> 4 - const totalPacket = packet[1] & 0x0f - const encrypted = packet[2] - const commandId = packet[3] + 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, packet.slice(4) + !! encrypted, commandId, new Uint8Array(view.buffer.slice(4)) ) } } @@ -356,7 +365,7 @@ class IncomingMessage { class CompleteMessage { /** * @param {number} commandId - The command ID of the message. - * @param {Uint8Array} payload - The message payload. + * @param {DataView} payload - The message payload. */ constructor(commandId, payload) { this.commandId = commandId @@ -711,12 +720,11 @@ class CommunicationForm extends Form { 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] + const length = message.payload.getUint8(0) + if (length === 4) { + const deviceId = message.payload.getUint32(1) + this.#pairHandler(deviceId) } - this.#pairHandler(deviceId) } this.transitToSuccessResultForm() -- cgit v1.2.3 From 7acc874c00bbdd3c389fa17176abf528ab6d5c0f Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 22:19:34 +0800 Subject: increase countdown seconds --- main.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index 40023ba..41bf8e0 100644 --- a/main.mjs +++ b/main.mjs @@ -630,7 +630,7 @@ class CommunicationForm extends Form { #setupCounter() { this.#description = this.form.querySelector('.form__description') this.#descriptionText = this.#description ? this.#description.textContent : '' - this.#countdown = new Countdown(this.#description, 10) + this.#countdown = new Countdown(this.#description, 30) this.#countdown.onComplete(() => { this.#progress.clear() this.disconnectIfNeeded() -- cgit v1.2.3 From f12a332a6d536027c5fc96ef41109215ec769571 Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sat, 12 Jul 2025 22:27:35 +0800 Subject: http --- main.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index 41bf8e0..04d7c02 100644 --- a/main.mjs +++ b/main.mjs @@ -851,7 +851,10 @@ class SuccessForm extends Form { return null } - const response = await fetch(`https://i.airmx.cn/exchange?device=${this.#deviceId}`) + // 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}`) if (! response.ok) { throw new Error('Could not retrieve the device key.') -- cgit v1.2.3 From aa757aef706f36afd20da9ec051a1452ab5613ca Mon Sep 17 00:00:00 2001 From: Li Zhineng Date: Sun, 13 Jul 2025 16:24:09 +0800 Subject: Clock.delay --- main.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'main.mjs') diff --git a/main.mjs b/main.mjs index 04d7c02..5900c6d 100644 --- a/main.mjs +++ b/main.mjs @@ -52,7 +52,7 @@ class Dispatcher { 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) + await Clock.delay(500) } } @@ -239,8 +239,10 @@ class RequestIdentityCommand extends Command { } } -async function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) +class Clock { + static delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } } class IncomingMessageHandler { -- 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 'main.mjs') diff --git a/index.html b/index.html index 2f6c920..0f229ff 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,8 @@ +

    +