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
-
-
-
-
- Your browser does not support the Web Bluetooth API. Please switch to Google Chrome and try again.
-
-
-
- Setup AIRMX Pro
-
-
-
-
-
-
-
-
-
-
-
+
+ Setup AIRMX device
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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(-)
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(-)
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(-)
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(-)
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?
+
+