Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/cubing/bluetooth/connect/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { debugLog } from "../debug";
import { getMacAddressProvider, type MacAddressProviderOption } from "../mac";
import type { BluetoothConfig } from "../smart-puzzle/bluetooth-puzzle";

/******** requestOptions ********/
Expand Down Expand Up @@ -32,6 +33,7 @@ function requestOptions<T>(

export interface BluetoothConnectOptions {
acceptAllDevices?: boolean;
macAddressProvider?: MacAddressProviderOption;
}

// We globally track the number of connection failures,
Expand Down Expand Up @@ -83,10 +85,14 @@ export async function bluetoothConnect<T>(
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.");
}
142 changes: 142 additions & 0 deletions src/cubing/bluetooth/mac.ts
Original file line number Diff line number Diff line change
@@ -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<string>;

/**
* 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<string> {
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<string> {
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 = `
<h2>Enter your bluetooth device's MAC address</h2>
<form>
<input type="text" placeholder="MAC Address">
<p style="color: red;"></p>
<button type="submit">Submit</button>
</form>
`;

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();
});
}
14 changes: 9 additions & 5 deletions src/cubing/bluetooth/smart-puzzle/Heykube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<HeykubeCube> {
public static async connect({
server,
device,
}: ConnectionArguments): Promise<HeykubeCube> {
const service = await server.getPrimaryService(UUIDs.heykubeService);
debugLog("Service:", service);

Expand Down
17 changes: 8 additions & 9 deletions src/cubing/bluetooth/smart-puzzle/bluetooth-puzzle.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -31,16 +32,14 @@ export interface OrientationEvent {
debug?: Record<string, unknown>;
}

export type ConnectionArguments = {
server: BluetoothRemoteGATTServer;
device: BluetoothDevice;
macAddressProvider: MacAddressProvider;
};

export interface BluetoothConfig<T> {
connect:
| ((
server: BluetoothRemoteGATTServer,
device: BluetoothDevice,
) => Promise<T>)
| ((
server: BluetoothRemoteGATTServer,
device?: BluetoothDevice,
) => Promise<T>);
connect: (connectionArguments: ConnectionArguments) => Promise<T>;
// TODO: Can we reuse `filters`?
prefixes: string[]; // `[""]` for GiiKER
filters: BluetoothLEScanFilter[];
Expand Down
12 changes: 8 additions & 4 deletions src/cubing/bluetooth/smart-puzzle/gan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<GanCube> {
public static async connect({
server,
}: ConnectionArguments): Promise<GanCube> {
const ganCubeService = await server.getPrimaryService(UUIDs.ganCubeService);
debugLog("Service:", ganCubeService);

Expand Down
12 changes: 8 additions & 4 deletions src/cubing/bluetooth/smart-puzzle/giiker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -112,9 +116,9 @@ async function decodeState(data: Uint8Array): Promise<Uint8Array> {

/** @category Smart Puzzles */
export class GiiKERCube extends BluetoothPuzzle {
public static async connect(
server: BluetoothRemoteGATTServer,
): Promise<GiiKERCube> {
public static async connect({
server,
}: ConnectionArguments): Promise<GiiKERCube> {
const cubeService = await server.getPrimaryService(UUIDs.cubeService);
debugLog("Service:", cubeService);

Expand Down
12 changes: 8 additions & 4 deletions src/cubing/bluetooth/smart-puzzle/gocube.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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<GoCube> {
public static async connect({
server,
}: ConnectionArguments): Promise<GoCube> {
const service = await server.getPrimaryService(UUIDs.goCubeService);
debugLog({ service });
const goCubeStateCharacteristic = await service.getCharacteristic(
Expand Down
12 changes: 8 additions & 4 deletions src/cubing/bluetooth/smart-puzzle/qiyi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -230,9 +234,9 @@ export class QiyiCube extends BluetoothPuzzle {
];
private batteryLevel: number = 100;

public static async connect(
server: BluetoothRemoteGATTServer,
): Promise<BluetoothPuzzle> {
public static async connect({
server,
}: ConnectionArguments): Promise<BluetoothPuzzle> {
const aesKey = await importKey(
new Uint8Array([
87, 177, 249, 171, 205, 90, 232, 167, 156, 185, 140, 231, 87, 140, 81,
Expand Down
10 changes: 5 additions & 5 deletions src/cubing/bluetooth/smart-robot/GanRobot.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
);
Expand Down
10 changes: 5 additions & 5 deletions src/cubing/bluetooth/smart-timer/GanTimer.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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,
);
Expand Down