summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.editorconfig8
-rw-r--r--app.css313
-rw-r--r--assets/airmx-pro.pngbin0 -> 440464 bytes
-rw-r--r--assets/indicator-failure.pngbin0 -> 50988 bytes
-rw-r--r--assets/indicator-success.pngbin0 -> 56245 bytes
-rw-r--r--index.html171
-rw-r--r--main.mjs460
7 files changed, 751 insertions, 201 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ee51031
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,8 @@
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
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
--- /dev/null
+++ b/assets/airmx-pro.png
Binary files differ
diff --git a/assets/indicator-failure.png b/assets/indicator-failure.png
new file mode 100644
index 0000000..c01efd0
--- /dev/null
+++ b/assets/indicator-failure.png
Binary files differ
diff --git a/assets/indicator-success.png b/assets/indicator-success.png
new file mode 100644
index 0000000..1bd449f
--- /dev/null
+++ b/assets/indicator-success.png
Binary files differ
diff --git a/index.html b/index.html
index b2a6736..46b069a 100644
--- a/index.html
+++ b/index.html
@@ -1,26 +1,149 @@
<html>
- <head>
- <title>Setup AIRMX device</title>
- <script defer src="main.mjs"></script>
- </head>
- <body>
- <p id="unsupported-message" style="display: none;">
- Your browser does not support the Web Bluetooth API. Please switch to Google Chrome and try again.
- </p>
-
- <main>
- <h1>Setup AIRMX Pro</h1>
- <div>
- <input type="text" id="ssid" placeholder="SSID" />
- </div>
- <div>
- <input type="password" id="password" placeholder="Password" />
- </div>
- <div>
- <button id="connect" type="button" onclick="connect()">
- Connect
- </button>
- </div>
- </main>
- </body>
+<head>
+ <title>Setup AIRMX device</title>
+ <script defer src="main.mjs"></script>
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet">
+ <link href="app.css" rel="stylesheet">
+</head>
+<body>
+ <header class="page__header">
+ <svg xmlns="http://www.w3.org/2000/svg" width="158" height="28" fill="none">
+ <path fill="#fff" d="m134.916 20-2.395-4.258h-.085l-1.704-1.734v-.049L127.078 8h2.973l2.423 4.478h.076l1.732 1.498v.048L138.003 20h-3.087Zm-7.913 0 3.729-5.976v-.048l1.704-1.498h.085L134.935 8h2.992l-3.645 5.96v.048l-1.732 1.734h-.076L130.06 20h-3.057ZM113.998 20V8h1.745l3.218 4.844h.078L122.257 8h1.745v12h-2.506v-4.958l.039-2.23h-.087l-2.087 3.264h-.722l-2.087-3.265h-.087l.039 2.231V20h-2.506ZM103.058 15.653V13.73h2.346c.405 0 .765-.076 1.079-.228.321-.152.573-.366.756-.643.183-.277.275-.597.275-.96 0-.565-.21-1.007-.628-1.327-.412-.32-.939-.48-1.58-.48h-2.248V8h2.66c.831 0 1.583.141 2.257.423.68.283 1.22.692 1.619 1.23.399.537.599 1.194.599 1.97 0 .754-.21 1.435-.628 2.043-.412.608-1.004 1.091-1.776 1.45-.772.357-1.688.537-2.748.537h-1.983ZM100.998 20V8h2.708v12h-2.708Zm6.938 0-2.895-4.681 2.021-1.384L110.998 20h-3.062ZM92.001 20V8h2.993v12h-2.993Zm-3.003 0v-2.035h9V20h-9Zm0-9.965V8h9v2.035h-9ZM74.998 20l4.15-12h2.698l4.152 12h-2.784l-.985-3.216-.21-.887-1.483-5.088h-.077l-1.482 5.088-.21.887L77.78 20h-2.783Zm2.582-2.605v-1.962h5.835v1.962H77.58ZM56.498 20V8h1.905l4.242 6.334.733 1.197h.078l-.078-2.402V8h2.62v12h-1.916l-4.222-6.407-.733-1.246h-.059l.049 2.508V20h-2.62ZM44.998 20.002v-12h8.5v2.093h-5.654v2.808h4.731v2.036h-4.73v2.971h5.653v2.092h-8.5ZM35.152 15.859v-2.076h2.088a2.38 2.38 0 0 0 1.087-.236 1.792 1.792 0 0 0 1.001-1.637c0-.559-.2-1.001-.6-1.327-.395-.325-.925-.488-1.593-.488h-1.983V8.002h2.164c.954 0 1.78.158 2.48.473.698.314 1.239.76 1.62 1.335.388.575.582 1.25.582 2.027 0 .792-.194 1.492-.582 2.1-.388.603-.931 1.075-1.63 1.417-.693.336-1.497.505-2.412.505h-2.222Zm-2.154 4.143v-12h2.707v12h-2.707ZM24.988 20c-1.058 0-1.96-.236-2.708-.71-.742-.472-1.309-1.154-1.702-2.047-.387-.898-.58-1.981-.58-3.251 0-1.27.193-2.35.58-3.243.393-.892.96-1.572 1.702-2.04.741-.473 1.644-.709 2.708-.709 1.05 0 1.95.236 2.698.71.748.467 1.319 1.15 1.712 2.047.4.892.6 1.97.6 3.235 0 1.27-.197 2.353-.59 3.251-.394.893-.964 1.575-1.712 2.048-.748.473-1.65.709-2.708.709Zm0-2.08c.522 0 .96-.154 1.315-.462.355-.308.622-.754.803-1.338.187-.585.28-1.294.28-2.128-.006-.834-.103-1.54-.29-2.12-.187-.584-.46-1.027-.822-1.33-.355-.308-.783-.462-1.286-.462-.51 0-.941.156-1.296.47-.348.308-.616.754-.803 1.338-.187.58-.28 1.28-.28 2.104 0 .823.093 1.527.28 2.112.194.584.468 1.033.822 1.346.355.314.78.47 1.277.47Z"/>
+ </svg>
+ </header>
+ <main class="container">
+ <form id="form-unsupported" class="form">
+ <p class="form__text">
+ Oops! Your browser doesn't support the Web Bluetooth API. To set up your AIRMX Pro, please consider switching to Google Chrome 56+ or Microsoft Edge 79+.
+ </p>
+ </form>
+
+ <form id="form-welcome" class="form">
+ <header class="form__header">
+ <h1 class="form__title form__title--gradient">Setup AIRMX Pro</h1>
+ <p class="form__description">Connect your AIRMX Pro to the internet</p>
+ </header>
+
+ <p class="form__text">
+ As you may know, the AIRMX servers are no longer available. During setup, the device will
+ send a registration request to the manufacturer's API endpoint to verify its identity. You must set up a
+ mock API server to complete the process. Click here for instructions.
+ </p>
+
+ <footer class="form__footer">
+ <button type="submit" class="button">
+ GET STARTED
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fafafa" class="button__icon">
+ <path d="M11.131 8.75H2v-1.5h9.131l-4.2-4.2L8 2l6 6-6 6-1.069-1.05 4.2-4.2Z" />
+ </svg>
+ </button>
+ </footer>
+ </form>
+
+ <form id="form-wifi-credentials" class="form">
+ <header class="form__header">
+ <h1 class="form__title">Configure Wi-Fi Credentials</h1>
+ <p class="form__description">Step 1 of 3</p>
+ </header>
+
+ <p class="form__text">
+ First, connect the machine to the internet using the Wi-Fi credentials. Note that the device may only
+ support a 2.4 GHz network.
+ </p>
+
+ <input type="text" id="ssid" class="input" placeholder="SSID" />
+ <input type="password" id="password" class="input" placeholder="Password" />
+
+ <footer class="form__footer">
+ <button type="submit" class="button">
+ NEXT
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fafafa" class="button__icon">
+ <path d="M11.131 8.75H2v-1.5h9.131l-4.2-4.2L8 2l6 6-6 6-1.069-1.05 4.2-4.2Z" />
+ </svg>
+ </button>
+ </footer>
+ </form>
+
+ <form id="form-pairing-activation" class="form">
+ <header class="form__header">
+ <h1 class="form__title">Activate Paring Mode</h1>
+ <p class="form__description">Step 2 of 3</p>
+ </header>
+
+ <p class="form__text">
+ We're almost there! To activate pairing mode, press and hold the AI button
+ <span class="physical-button"><span class="physical-button__ai"></span></span>
+ and the fan speed increase button
+ <span class="physical-button"><span class="physical-button__fan"></span></span>
+ simultaneously for 3 seconds.
+ </p>
+
+ <p class="form__text">
+ Release all the buttons once the green Bluetooth icon
+ <span class="physical-button"><span class="physical-button__b-circle"></span></span>
+ starts flashing.
+ </p>
+
+ <footer class="form__footer">
+ <button type="submit" class="button">
+ I'M READY
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fafafa" class="button__icon">
+ <path d="M11.131 8.75H2v-1.5h9.131l-4.2-4.2L8 2l6 6-6 6-1.069-1.05 4.2-4.2Z" />
+ </svg>
+ </button>
+ </footer>
+ </form>
+
+ <form id="form-communication" class="form">
+ <header class="form__header">
+ <h1 class="form__title">Communicating with Device</h1>
+ <p class="form__description">Step 3 of 3</p>
+ </header>
+ <p class="form__text">
+ One more thing, let's choose the AIRMX Pro device from the Bluetooth scanning list.
+ </p>
+ </form>
+
+ <form id="form-result-success" class="form">
+ <header class="form__header">
+ <img src="assets/indicator-success.png" class="form__indicator" />
+ <h1 class="form__title">Congratulations! Your device has been successfully set up.</h1>
+ </header>
+ <p class="form__text">
+ Here is your device key. Please copy and save it in a safe place. You may need to integrate your device
+ with another platform to control or monitor it remotely.
+ </p>
+ <p class="form__text">
+ If you lose it, don't worry. You can reset and re-pair your device on this page at any time.
+ </p>
+ <div class="input-group">
+ <input id="device-key" class="input" type="text" value="rqpwp3sv9ximkmspery5nogc5yprhzjt" readonly />
+ <button class="button" type="button">Copy</button>
+ </div>
+ </form>
+
+ <form id="form-result-failure" class="form">
+ <header class="form__header">
+ <img src="assets/indicator-failure.png" class="form__indicator" />
+ <h1 class="form__title">Oops! Something went wrong.</h1>
+ </header>
+ <p class="form__text">
+ Time's up. The device didn't respond. Here are some tips: Please
+ confirm that the Wi-Fi credentials are correct and that the access
+ point is a 2.4 GHz network. Also, check to see if the mock server
+ is up and running.
+ </p>
+ <footer class="form__footer">
+ <button type="submit" class="button">
+ RETRY
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fafafa" class="button__icon">
+ <path d="M11.131 8.75H2v-1.5h9.131l-4.2-4.2L8 2l6 6-6 6-1.069-1.05 4.2-4.2Z" />
+ </svg>
+ </button>
+ </footer>
+ </form>
+ </main>
+</body>
</html>
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()