summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.editorconfig8
-rw-r--r--app.css384
-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.html200
-rw-r--r--main.mjs1135
7 files changed, 1550 insertions, 177 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..ce31a3f
--- /dev/null
+++ b/app.css
@@ -0,0 +1,384 @@
+/**
+ * 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;
+ grid: max-content max-content 10rem / auto;
+ 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;
+ min-height: 100vh;
+}
+
+.page__header {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ margin-block-start: 2rem;
+}
+
+.container {
+ position: relative;
+ margin-block-start: 3rem;
+ margin-inline: 1.25rem;
+ grid-row: 2;
+ padding: 2rem 1.5rem 3rem;
+ 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(--color-white);
+}
+
+.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__link {
+ font-size: 0.9375rem;
+ line-height: 1.5;
+ color: var(--color-zinc-50);
+}
+
+.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;
+ 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);
+}
+
+.help-text {
+ margin-block-start: 1rem;
+ font-size: 0.875rem;
+ color: var(--color-zinc-600);
+}
+
+.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;
+}
+
+.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;
+}
+
+.retry-message--shown {
+ display: block;
+}
+
+@media (min-width: 768px) {
+ body {
+ grid-template-columns: 40rem;
+ }
+
+ .page__header {
+ margin-block-start: 4.5rem;
+ }
+
+ .container {
+ padding: 3rem 4rem 4rem;
+ margin-inline: 0;
+ }
+}
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..f637744 100644
--- a/index.html
+++ b/index.html
@@ -1,26 +1,182 @@
+<!DOCTYPE html>
<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.
+<head>
+ <title>Setup AIRMX device</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <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" />
+
+ <p data-alert></p>
+
+ <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>
+ <p class="form__text retry-message">
+ 👀 The list has been dismissed.
+ <a href="javascript:void(0)" class="form__link retry-link">Try again?</a>
+ </p>
+ <ol data-slot="progress"></ol>
+ </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" data-key></div>
+ <p data-alert></p>
+ </form>
- <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>
+ <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..3baeaed 100644
--- a/main.mjs
+++ b/main.mjs
@@ -1,229 +1,1054 @@
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
+ }
+}
+
+class Dispatcher {
+ #characteristic
+ #sequenceNumber = 1
+ #chunkSize = 16
+
+ constructor(characteristic) {
+ this.#characteristic = characteristic
+ }
+
+ 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 Clock.delay(500)
+ }
+ }
+
+ #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
)
+ ]
}
- get name() {
- return this.#deviceName
+ 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))
}
- get primaryServiceUuid() {
- return this.#primaryServiceUuid
+ return packets
+ }
+
+ #packetHeader(sequenceNumber, currentPacket, totalPacket, commandId) {
+ return new Uint8Array([
+ sequenceNumber,
+ currentPacket << 4 | totalPacket,
+ 0x00, // Unencrypted flag
+ commandId
+ ])
+ }
+}
+
+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
}
+ }
- get writeCharacteristicUuid() {
- return this.#writeCharacteristicUuid
+ /**
+ * @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
+ }
- get notifyCharacteristicUuid() {
- return this.#notifyCharacteristicUuid
+ #handleNotification(event) {
+ if (this.#notificationHandler) {
+ this.#notificationHandler(event)
}
+ }
}
-class Dispatcher {
- #characteristic
- #sequenceNumber = 1
- #chunkSize = 16
+class Command {
+ get commandId() {
+ throw new Error('The command ID does not exist.')
+ }
+
+ get payload() {
+ throw new Error('The payload does not exist.')
+ }
+}
+
+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
+ ])
+ }
+}
+
+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 RegisterCommand extends Command {
+ get commandId() {
+ return 0x16
+ }
+
+ get payload() {
+ return new Uint8Array([
+ //
+ ])
+ }
+}
- constructor(characteristic) {
- this.#characteristic = characteristic
+class Clock {
+ static delay(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms))
+ }
+}
+
+class IncomingMessageHandler {
+ /** @type {IncomingMessage[]} */
+ #bag = []
+
+ #messageHandler = null
+
+ /**
+ * @param {DataView} view
+ */
+ handle(view) {
+ const message = IncomingMessage.parse(view)
+ 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()
}
+ }
- async dispatch(command) {
- const data = this.#chunk(command.payload, command)
+ #processBag() {
+ const bag = [...this.#bag]
+ const lastMessage = bag.at(-1)
- 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)
- }
+ if (bag.length !== lastMessage.totalPacket) {
+ throw new Error('Incomplete message received.')
}
- #chunk(data, command) {
- const packets = this.#chunked(data, this.#chunkSize)
- const total = packets.length
+ let data = new Uint8Array()
- if (total === 0) {
- return [
- this.#packetHeader(
- this.#sequenceNumber++, 1, 1, // 1 of 1 packet
- command.commandId
- )
- ]
- }
+ for (const [index, message] of bag.entries()) {
+ if (message.currentPacket !== index + 1) {
+ throw new Error(`Message packet ${index + 1} is missing.`)
+ }
- return packets.map((chunk, index) => {
- return new Uint8Array([
- ...this.#packetHeader(
- this.#sequenceNumber++, index + 1, total,
- command.commandId
- ),
- ...chunk
- ])
- })
+ const temp = new Uint8Array(data.byteLength + message.payload.byteLength)
+ temp.set(data)
+ temp.set(message.payload, data.byteLength)
+ data = temp
}
- /**
- * 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 = []
+ const completeMessage = new CompleteMessage(
+ lastMessage.commandId, new DataView(data.buffer)
+ )
+
+ this.#notify(completeMessage)
+ this.#clearBag()
+ }
- for (let i = 0; i < data.length; i += size) {
- packets.push(data.slice(i, i + size))
- }
+ #clearBag() {
+ this.#bag = []
+ }
- return packets
+ /**
+ * @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
+ }
- #packetHeader(sequenceNumber, currentPacket, totalPacket, commandId) {
- return new Uint8Array([
- sequenceNumber,
- currentPacket << 4 | totalPacket,
- 0x00, // Unencrypted flag
- commandId
- ])
+ /**
+ * @param {DataView} view - The raw packet data.
+ * @returns {IncomingMessage}
+ */
+ static parse(view) {
+ if (view.byteLength < 4) {
+ throw new Error('Invalid packet length.')
}
+
+ 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, new Uint8Array(view.buffer.slice(4))
+ )
+ }
}
-class Command {
- get commandId() {
- throw new Error('The command ID does not exist.')
+class CompleteMessage {
+ /**
+ * @param {number} commandId - The command ID of the message.
+ * @param {DataView} payload - The message payload.
+ */
+ constructor(commandId, payload) {
+ this.commandId = commandId
+ this.payload = payload
+ }
+}
+
+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.')
}
+ }
- get payload() {
- throw new Error('The payload does not exist.')
+ 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
}
-}
-class HandshakeCommand extends Command {
- get commandId() {
- return 0x0b
+ clearInterval(this.#clock)
+ this.#clock = null
+
+ if (this.#stopHandler) {
+ this.#stopHandler()
}
+ }
+
+ onStop(callback) {
+ this.#stopHandler = callback
+ }
- 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
- ])
+ onComplete(callback) {
+ this.#completeHandler = callback
+ }
+
+ notifyComplete() {
+ if (this.#completeHandler) {
+ this.#completeHandler()
}
+ }
}
-class ConfigureWifiCommand extends Command {
- #ssid
- #password
+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.`)
+ }
+ }
+
+ display() {
+ this.form.classList.add(this.#activeClassName)
- constructor(ssid, password) {
- super()
- this.#ssid = ssid
- this.#password = password
+ if (typeof this.onDisplay === 'function') {
+ this.onDisplay()
}
+ }
+
+ hide() {
+ this.form.classList.remove(this.#activeClassName)
- get commandId() {
- return 0x15
+ if (typeof this.onHide === 'function') {
+ this.onHide()
}
+ }
+}
+
+class ProgressibleForm extends Form {
+ #nextForm = null
- get payload() {
- const encoder = new TextEncoder()
- const ssid = encoder.encode(this.#ssid)
- const password = encoder.encode(this.#password)
+ nextTo(form) {
+ this.#nextForm = form
+ return this
+ }
- return new Uint8Array([
- ssid.length, ...ssid,
- password.length, ...password
- ])
+ transitToNextForm() {
+ if (this.#nextForm === null) {
+ return
}
+
+ this.hide()
+ this.#nextForm.display()
+ }
+}
+
+class WelcomeForm extends ProgressibleForm {
+ constructor(id) {
+ super(id)
+ this.form.addEventListener('submit', this.handleSubmit.bind(this))
+ }
+
+ handleSubmit(event) {
+ event.preventDefault()
+ this.transitToNextForm()
+ }
}
-class RequestIdentityCommand extends Command {
- get commandId() {
- return 0x16
+class WifiCredentialsForm extends ProgressibleForm {
+ #alert
+ #submitCallback = null
+
+ constructor(id) {
+ super(id)
+ this.#alert = new Alert(this.form.querySelector('[data-alert]'))
+ this.form.addEventListener('submit', this.handleSubmit.bind(this))
+ }
+
+ handleSubmit(event) {
+ event.preventDefault()
+ const { elements } = event.target
+ try {
+ const [ssid, password] = this.validate(elements.ssid.value, elements.password.value)
+ if (this.#submitCallback) {
+ this.#submitCallback({ ssid, password })
+ }
+ this.transitToNextForm()
+ } catch (error) {
+ this.#alert.show(error.message)
+ }
+ }
+
+ /**
+ * Validate with the IEEE 802.11 standard.
+ *
+ * @param {string} ssid - The Wi-Fi name
+ * @param {string} password - The Password (WPA/WPA2-PSK)
+ */
+ validate(ssid, password) {
+ if (ssid === '' ) {
+ throw new Error('SSID cannot be empty.')
+ }
+
+ // The SSID is up to 32 characters
+ if (ssid.length > 32) {
+ throw new Error('SSID cannot be longer than 32 characters.')
}
- get payload() {
- return new Uint8Array([
- //
- ])
+ if (password === '' ) {
+ throw new Error('Password cannot be empty.')
}
+
+ // The password is between 8 and 63 characters
+ if (password.length < 8 || password.length > 63) {
+ throw new Error('Password must be between 8 and 63 characters long.')
+ }
+
+ return [ssid, password]
+ }
+
+ onSubmit(callback) {
+ this.#submitCallback = callback
+ return this
+ }
}
-async function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms))
+class PairingActivationForm extends ProgressibleForm {
+ constructor(id) {
+ super(id)
+ this.form.addEventListener('submit', this.handleSubmit.bind(this))
+ }
+
+ handleSubmit(event) {
+ event.preventDefault()
+ this.transitToNextForm()
+ }
}
-async function connect() {
- const ssid = document.getElementById('ssid')
- const password = document.getElementById('password')
+class CommunicationForm extends Form {
+ #handler
+ #messages = null
- if (ssid.value === '' || password.value === '') {
- return
+ /** @type {Countdown} */
+ #countdown
+
+ /** @type {HTMLElement|null} */
+ #description
+
+ /** @type {string} */
+ #descriptionText
+
+ /** @type {Progress} */
+ #progress
+
+ #handshakeCommand
+ #wifiCredentialsCommand
+ #registerCommand
+
+ #successForm = null
+ #failureForm = null
+
+ #retryMessage = null
+ #retryMessageClassName = 'retry-message'
+ #retryMessageShownClassName = 'retry-message--shown'
+
+ #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.
+ */
+ constructor(id, handler) {
+ super(id)
+ 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.#setupCounter()
+ this.#setupProgress()
+ this.#handshakeCommand = new HandshakeCommand()
+ this.#wifiCredentialsCommand = new ConfigureWifiCommand('', '')
+ this.#registerCommand = new RegisterCommand()
+ 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()
+ })
}
+ }
+
+ onDisplay() {
+ this.startPairing()
+ }
+
+ #setupCounter() {
+ this.#description = this.form.querySelector('.form__description')
+ this.#descriptionText = this.#description ? this.#description.textContent : ''
+ this.#countdown = new Countdown(this.#description, 30)
+ 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: 'register', name: 'Register the device' }
+ ])
+ }
+
+ async startPairing() {
+ try {
+ await this.connect()
+ } catch {
+ this.showRetryOption()
+ }
+ }
+
+ async connect() {
+ await this.#handler.connect()
+
+ this.#countdown.start()
+ this.#progress.render()
+
+ this.#progress.markAsCurrent('handshake')
+ await this.#handler.dispatch(this.#handshakeCommand)
+ }
+
+ async disconnectIfNeeded() {
+ await this.#handler.disconnect()
+ }
+
+ /**
+ * @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.#registerCommand.commandId:
+ this.handleRegisterMessage(message)
+ break
+ default:
+ console.warn(`Unknown command ID: ${message.commandId}`)
+ break
+ }
+ }
+
+ /**
+ * @param {CompleteMessage} message
+ */
+ handleHandshakeMessage(message) {
+ this.#progress.markAsComplete('handshake')
+ this.#progress.markAsCurrent('wifi')
+ this.#handler.dispatch(this.#wifiCredentialsCommand)
+ }
- const device = Device.airmxPro()
- const wifiCredentials = { ssid: ssid.value, password: password.value }
- await connectToDevice(device, wifiCredentials)
+ /**
+ * @param {CompleteMessage} message
+ */
+ handleWifiCredentialsMessage(message) {
+ this.#progress.markAsComplete('wifi')
+ this.#progress.markAsCurrent('register')
+ this.#handler.dispatch(this.#registerCommand)
+ }
+
+ /**
+ * @param {CompleteMessage} message
+ */
+ handleRegisterMessage(message) {
+ this.#countdown.stop()
+ this.#progress.markAsComplete('register')
+ this.#progress.clear()
+ this.disconnectIfNeeded()
+
+ if (this.#pairHandler) {
+ const length = message.payload.getUint8(0)
+ if (length === 4) {
+ const deviceId = message.payload.getUint32(1)
+ this.#pairHandler(deviceId)
+ }
+ }
+
+ this.transitToSuccessResultForm()
+ }
+
+ 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
+ }
+
+ failTo(form) {
+ this.#failureForm = form
+ return this
+ }
+
+ wifiCredentialsUsing(credentials) {
+ this.#wifiCredentialsCommand = new ConfigureWifiCommand(credentials.ssid, credentials.password)
+ return this
+ }
+
+ /**
+ * @param {CallableFunction} handler
+ */
+ onPair(handler) {
+ this.#pairHandler = handler
+ return this
+ }
+
+ transitToSuccessResultForm() {
+ this.hide()
+ this.#successForm?.display()
+ }
+
+ transitToFailureResultForm() {
+ this.hide()
+ this.#failureForm?.display()
+ }
}
-async function connectToDevice(device, wifiCredentials) {
- const bluetoothDevice = await navigator.bluetooth.requestDevice({
- filters: [{ name: device.name }],
- optionalServices: [device.primaryServiceUuid]
+class SuccessForm extends Form {
+ /** @type {HTMLElement} */
+ #inputGroup
+
+ /** @type {HTMLElement} */
+ #input
+
+ /** @type {HTMLElement} */
+ #button
+
+ #alert
+
+ /** @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.#alert = new Alert(this.form.querySelector('[data-alert]'))
+ 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
+ }
+
+ // 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}`, {
+ signal: AbortSignal.timeout(3000)
})
- const server = await bluetoothDevice.gatt.connect()
- const service = await server.getPrimaryService(device.primaryServiceUuid)
+ 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.show('Copied to the clipboard.')
+ } catch {
+ this.#alert.show('Unable to copy to the clipboard because of permission issues.')
+ }
+ }
+
+ #handleDeviceKeyRetrievalFailure() {
+ this.#input.value = 'Could not retrieve the device key.'
+ }
- const writeCharacteristic = await service.getCharacteristic(device.writeCharacteristicUuid)
- const notifyCharacteristic = await service.getCharacteristic(device.notifyCharacteristicUuid)
+ /**
+ * @param {number} deviceId
+ */
+ deviceIdUsing(deviceId) {
+ this.#deviceId = deviceId
+ return this
+ }
+}
- await notifyCharacteristic.startNotifications()
- notifyCharacteristic.addEventListener('characteristicvaluechanged', handleDeviceResponse)
+class FailureForm 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.transitToNextForm()
+ }
}
-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'))
+class Alert {
+ /** @type {HTMLElement} */
+ #el
+
+ /**
+ * @param {HTMLElement|null} el - The HTML element to mount the alert component.
+ */
+ constructor(el) {
+ if (el === null) {
+ throw new Error('The HTML element to mount the alert component does not exist.')
+ }
+ this.#el = el
+ }
+
+ /**
+ * @param {string} message
+ */
+ show(message) {
+ if (! this.#el.classList.contains('help-text')) {
+ this.#el.classList.add('help-text')
}
- console.log(`Received data from device: ${receivedBytes.join(' ')}`)
+ this.#el.textContent = message
+ }
}
-function supportBluetoothApi() {
- return 'bluetooth' in navigator
+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
+ }
}
-if (! supportBluetoothApi()) {
- const unsupportedMessage = document.getElementById('unsupported-message')
- unsupportedMessage.style.display = 'block'
+class Application {
+ static supportBluetoothApi() {
+ return 'bluetooth' in navigator
+ }
+
+ static run() {
+ if (! this.supportBluetoothApi()) {
+ const unsupportedForm = new Form('form-unsupported')
+ unsupportedForm.display()
+ return
+ }
+
+ 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 main = document.querySelector('main')
- main.style.display = 'none'
+ const wifiCredentialsForm = new WifiCredentialsForm('form-wifi-credentials')
+ .nextTo(pairingActivationForm)
+ .onSubmit((credentials) => {
+ communicationForm.wifiCredentialsUsing(credentials)
+ })
+
+ // 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.
+ new WelcomeForm('form-welcome')
+ .nextTo(wifiCredentialsForm)
+ .display()
+ }
}
+
+Application.run()