diff --git a/src/cubing/bluetooth/connect/index.ts b/src/cubing/bluetooth/connect/index.ts index 1f2f79d44..885ecc4ad 100644 --- a/src/cubing/bluetooth/connect/index.ts +++ b/src/cubing/bluetooth/connect/index.ts @@ -1,4 +1,5 @@ import { debugLog } from "../debug"; +import { getMacAddressProvider, type MacAddressProviderOption } from "../mac"; import type { BluetoothConfig } from "../smart-puzzle/bluetooth-puzzle"; /******** requestOptions ********/ @@ -32,6 +33,7 @@ function requestOptions( export interface BluetoothConnectOptions { acceptAllDevices?: boolean; + macAddressProvider?: MacAddressProviderOption; } // We globally track the number of connection failures, @@ -83,10 +85,14 @@ export async function bluetoothConnect( for (const config of configs) { for (const prefix of config.prefixes) { if (name?.startsWith(prefix)) { - return config.connect(server, device); + return config.connect({ + server, + device, + macAddressProvider: getMacAddressProvider(options.macAddressProvider), + }); } } } - throw Error("Unknown Bluetooth devive."); + throw Error("Unknown Bluetooth device."); } diff --git a/src/cubing/bluetooth/mac.ts b/src/cubing/bluetooth/mac.ts new file mode 100644 index 000000000..377a44203 --- /dev/null +++ b/src/cubing/bluetooth/mac.ts @@ -0,0 +1,142 @@ +/** + * Type of MAC address provider to use when a bluetooth device's MAC address + * is needed and cannot automatically be determined, and as a fallback when + * automatically determining the MAC address fails. + * + * - `"PROMPT"`: Use the built-in prompt-based provider (default). + * - `"DIALOG"`: Use the built-in dialog-based provider. + * - {@link MacAddressProvider}: Use a custom MAC address provider. + */ +export type MacAddressProviderOption = + | "PROMPT" + | "DIALOG" + | MacAddressProvider + | undefined; + +/** Type representing a custom MAC address provider for bluetooth devices */ +export type MacAddressProvider = () => Promise; + +/** + * An error that should be thrown by a {@link MacAddressProvider} to + * signal that the user cancelled entering a MAC address. + */ +export class MacAddressCancelledError extends Error {} + +/** + * Converts a {@link MacAddressProviderOption} to a {@link MacAddressProvider}, + * using `"PROMPT"` as the default option. + * @param macAddressProviderOption {@link MacAddressProviderOption} provided by the user. + * @returns final MAC address provider that can be called by bluetooth device configs + */ +export function getMacAddressProvider( + macAddressProviderOption: MacAddressProviderOption, +): MacAddressProvider { + macAddressProviderOption ||= "PROMPT"; + + let provider: MacAddressProvider; + switch (macAddressProviderOption) { + case "DIALOG": + provider = dialogMacAddressProvider; + break; + + case "PROMPT": + provider = promptMacAddressProvider; + break; + + default: + provider = macAddressProviderOption; + break; + } + + return async () => { + const macAddress = (await provider()).trim(); + validateMacAddress(macAddress); + return macAddress; + }; +} + +const MAC_ADDRESS_REGEX = /^[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}$/; + +/** + * Validates the MAC address returned by a {@link MacAddressProvider}. + * @param macAddress MAC address returned by a {@link MacAddressProvider} + * @throws error if the MAC address is invalid + */ +function validateMacAddress(macAddress: string) { + if (!MAC_ADDRESS_REGEX.test(macAddress)) { + throw new Error(`Invalid MAC address: ${macAddress}`); + } +} + +/** + * Default prompt-based MAC address provider + * @returns Bluetooth device MAC address + */ +export async function promptMacAddressProvider(): Promise { + while (true) { + const address = prompt( + "Enter your bluetooth device's MAC address:", + )?.trim(); + if (address === undefined) { + // User cancelled prompt + throw new MacAddressCancelledError(); + } + const isValidMacAddress = MAC_ADDRESS_REGEX.test(address); + if (isValidMacAddress) { + return address; + } + } +} + +/** + * Default dialog-based MAC address provider + * @returns Bluetooth device MAC address + */ +export function dialogMacAddressProvider(): Promise { + return new Promise((resolve, reject) => { + // TODO: Replace with a cleaner web component implementation + const dialog = document.createElement("dialog"); + + // TODO: Add custom CSS styles + dialog.innerHTML = ` +

Enter your bluetooth device's MAC address

+
+ +

+ +
+ `; + + document.querySelector("body")!.appendChild(dialog); + const feedback = dialog.querySelector("p")!; + const macInput = dialog.querySelector("input")!; + + const button = dialog.querySelector("button")!; + + const validateInput = () => { + if (!MAC_ADDRESS_REGEX.test(macInput.value)) { + feedback.textContent = "Invalid MAC address."; + button.disabled = true; + } else { + feedback.textContent = ""; + button.disabled = false; + } + }; + + macInput.addEventListener("keyup", validateInput); + validateInput(); + + dialog.querySelector("form")!.addEventListener("submit", (e) => { + e.preventDefault(); + document.querySelector("body")!.removeChild(dialog); + resolve(macInput.value); + }); + + // TODO: clean up error handling + dialog.addEventListener("close", () => + reject(new MacAddressCancelledError()), + ); + + dialog.showModal(); + }); +} diff --git a/src/cubing/bluetooth/smart-puzzle/Heykube.ts b/src/cubing/bluetooth/smart-puzzle/Heykube.ts index a664a66ff..2254d1c5c 100644 --- a/src/cubing/bluetooth/smart-puzzle/Heykube.ts +++ b/src/cubing/bluetooth/smart-puzzle/Heykube.ts @@ -9,7 +9,11 @@ import { } from "../../protocol"; import { puzzles } from "../../puzzles"; import { debugLog } from "../debug"; -import { type BluetoothConfig, BluetoothPuzzle } from "./bluetooth-puzzle"; +import { + type BluetoothConfig, + BluetoothPuzzle, + type ConnectionArguments, +} from "./bluetooth-puzzle"; import { flipBitOrder } from "./endianness"; // TODO: Short IDs @@ -22,10 +26,10 @@ const UUIDs = { /** @category Smart Puzzles */ export class HeykubeCube extends BluetoothPuzzle { // We have to perform async operations before we call the constructor. - public static async connect( - server: BluetoothRemoteGATTServer, - device: BluetoothDevice, - ): Promise { + public static async connect({ + server, + device, + }: ConnectionArguments): Promise { const service = await server.getPrimaryService(UUIDs.heykubeService); debugLog("Service:", service); diff --git a/src/cubing/bluetooth/smart-puzzle/bluetooth-puzzle.ts b/src/cubing/bluetooth/smart-puzzle/bluetooth-puzzle.ts index dc2b5a817..5fa20cd56 100644 --- a/src/cubing/bluetooth/smart-puzzle/bluetooth-puzzle.ts +++ b/src/cubing/bluetooth/smart-puzzle/bluetooth-puzzle.ts @@ -1,5 +1,6 @@ import type { AlgLeaf } from "../../alg/alg-nodes/AlgNode"; import type { KPattern } from "../../kpuzzle/KPattern"; +import type { MacAddressProvider } from "../mac"; import { BasicRotationTransformer, type StreamTransformer, @@ -31,16 +32,14 @@ export interface OrientationEvent { debug?: Record; } +export type ConnectionArguments = { + server: BluetoothRemoteGATTServer; + device: BluetoothDevice; + macAddressProvider: MacAddressProvider; +}; + export interface BluetoothConfig { - connect: - | (( - server: BluetoothRemoteGATTServer, - device: BluetoothDevice, - ) => Promise) - | (( - server: BluetoothRemoteGATTServer, - device?: BluetoothDevice, - ) => Promise); + connect: (connectionArguments: ConnectionArguments) => Promise; // TODO: Can we reuse `filters`? prefixes: string[]; // `[""]` for GiiKER filters: BluetoothLEScanFilter[]; diff --git a/src/cubing/bluetooth/smart-puzzle/gan.ts b/src/cubing/bluetooth/smart-puzzle/gan.ts index afb4e2487..5c58a7b7d 100644 --- a/src/cubing/bluetooth/smart-puzzle/gan.ts +++ b/src/cubing/bluetooth/smart-puzzle/gan.ts @@ -9,7 +9,11 @@ import { unsafeDecryptBlock, } from "../../vendor/public-domain/unsafe-raw-aes/unsafe-raw-aes"; import { debugLog } from "../debug"; -import { type BluetoothConfig, BluetoothPuzzle } from "./bluetooth-puzzle"; +import { + type BluetoothConfig, + BluetoothPuzzle, + type ConnectionArguments, +} from "./bluetooth-puzzle"; import { getPatternData } from "./common"; // This needs to be short enough to capture 6 moves (OBQTM). @@ -238,9 +242,9 @@ async function getKey( /** @category Smart Puzzles */ export class GanCube extends BluetoothPuzzle { // We have to perform async operations before we call the constructor. - public static async connect( - server: BluetoothRemoteGATTServer, - ): Promise { + public static async connect({ + server, + }: ConnectionArguments): Promise { const ganCubeService = await server.getPrimaryService(UUIDs.ganCubeService); debugLog("Service:", ganCubeService); diff --git a/src/cubing/bluetooth/smart-puzzle/giiker.ts b/src/cubing/bluetooth/smart-puzzle/giiker.ts index 77e1b6ec7..5454c2c71 100644 --- a/src/cubing/bluetooth/smart-puzzle/giiker.ts +++ b/src/cubing/bluetooth/smart-puzzle/giiker.ts @@ -4,7 +4,11 @@ import { Move } from "../../alg"; import { KPattern, type KPatternData } from "../../kpuzzle"; import { experimental3x3x3KPuzzle } from "../../puzzles/cubing-private"; import { debugLog } from "../debug"; -import { type BluetoothConfig, BluetoothPuzzle } from "./bluetooth-puzzle"; +import { + type BluetoothConfig, + BluetoothPuzzle, + type ConnectionArguments, +} from "./bluetooth-puzzle"; const MESSAGE_LENGTH = 20; @@ -112,9 +116,9 @@ async function decodeState(data: Uint8Array): Promise { /** @category Smart Puzzles */ export class GiiKERCube extends BluetoothPuzzle { - public static async connect( - server: BluetoothRemoteGATTServer, - ): Promise { + public static async connect({ + server, + }: ConnectionArguments): Promise { const cubeService = await server.getPrimaryService(UUIDs.cubeService); debugLog("Service:", cubeService); diff --git a/src/cubing/bluetooth/smart-puzzle/gocube.ts b/src/cubing/bluetooth/smart-puzzle/gocube.ts index 1aeb27b82..ff754fb8d 100644 --- a/src/cubing/bluetooth/smart-puzzle/gocube.ts +++ b/src/cubing/bluetooth/smart-puzzle/gocube.ts @@ -1,7 +1,11 @@ import { Quaternion } from "three/src/math/Quaternion.js"; import { Alg, experimentalAppendMove, Move } from "../../alg"; import { debugLog } from "../debug"; -import { type BluetoothConfig, BluetoothPuzzle } from "./bluetooth-puzzle"; +import { + type BluetoothConfig, + BluetoothPuzzle, + type ConnectionArguments, +} from "./bluetooth-puzzle"; const UUIDs = { goCubeService: "6e400001-b5a3-f393-e0a9-e50e24dcca9e", @@ -45,9 +49,9 @@ const moveMap: Move[] = [ /** @category Smart Puzzles */ export class GoCube extends BluetoothPuzzle { // We have to perform async operations before we call the constructor. - public static async connect( - server: BluetoothRemoteGATTServer, - ): Promise { + public static async connect({ + server, + }: ConnectionArguments): Promise { const service = await server.getPrimaryService(UUIDs.goCubeService); debugLog({ service }); const goCubeStateCharacteristic = await service.getCharacteristic( diff --git a/src/cubing/bluetooth/smart-puzzle/qiyi.ts b/src/cubing/bluetooth/smart-puzzle/qiyi.ts index 164a015d1..c10247e53 100644 --- a/src/cubing/bluetooth/smart-puzzle/qiyi.ts +++ b/src/cubing/bluetooth/smart-puzzle/qiyi.ts @@ -8,7 +8,11 @@ import { unsafeDecryptBlock, unsafeEncryptBlock, } from "../../vendor/public-domain/unsafe-raw-aes/unsafe-raw-aes"; -import { type BluetoothConfig, BluetoothPuzzle } from "./bluetooth-puzzle"; +import { + type BluetoothConfig, + BluetoothPuzzle, + type ConnectionArguments, +} from "./bluetooth-puzzle"; import { getPatternData } from "./common"; const UUIDs = { @@ -230,9 +234,9 @@ export class QiyiCube extends BluetoothPuzzle { ]; private batteryLevel: number = 100; - public static async connect( - server: BluetoothRemoteGATTServer, - ): Promise { + public static async connect({ + server, + }: ConnectionArguments): Promise { const aesKey = await importKey( new Uint8Array([ 87, 177, 249, 171, 205, 90, 232, 167, 156, 185, 140, 231, 87, 140, 81, diff --git a/src/cubing/bluetooth/smart-robot/GanRobot.ts b/src/cubing/bluetooth/smart-robot/GanRobot.ts index 83a381938..3ed0ac9e8 100644 --- a/src/cubing/bluetooth/smart-robot/GanRobot.ts +++ b/src/cubing/bluetooth/smart-robot/GanRobot.ts @@ -1,6 +1,9 @@ import { Alg, Move as AlgNode, Move } from "../../alg"; import { cube3x3x3 } from "../../puzzles"; -import type { BluetoothConfig } from "../smart-puzzle/bluetooth-puzzle"; +import type { + BluetoothConfig, + ConnectionArguments as BluetoothConnectionArguments, +} from "../smart-puzzle/bluetooth-puzzle"; // TODO: Remove this. It's only used for debugging. function buf2hex(buffer: ArrayBuffer | Uint8Array): string { @@ -132,10 +135,7 @@ export class GanRobot extends EventTarget { } // We have to perform async operations before we call the constructor. - static async connect( - server: BluetoothRemoteGATTServer, - device: BluetoothDevice, - ) { + static async connect({ server, device }: BluetoothConnectionArguments) { const ganTimerService = await server.getPrimaryService( UUIDs.ganRobotService, ); diff --git a/src/cubing/bluetooth/smart-timer/GanTimer.ts b/src/cubing/bluetooth/smart-timer/GanTimer.ts index 717a3e6aa..5ebd515b2 100644 --- a/src/cubing/bluetooth/smart-timer/GanTimer.ts +++ b/src/cubing/bluetooth/smart-timer/GanTimer.ts @@ -1,5 +1,8 @@ import type { MillisecondTimestamp } from "../../twisty/controllers/AnimationTypes"; -import type { BluetoothConfig } from "../smart-puzzle/bluetooth-puzzle"; +import type { + BluetoothConfig, + ConnectionArguments, +} from "../smart-puzzle/bluetooth-puzzle"; // TODO: Short IDs const UUIDs = { @@ -37,10 +40,7 @@ export class GanTimer extends EventTarget { } // We have to perform async operations before we call the constructor. - static async connect( - server: BluetoothRemoteGATTServer, - device: BluetoothDevice, - ) { + static async connect({ server, device }: ConnectionArguments) { const ganTimerService = await server.getPrimaryService( UUIDs.ganTimerService, );