UNPKG

@vscubing/cubing

Version:

A collection of JavaScript cubing libraries.

1,994 lines (1,977 loc) 57.8 kB
import { binaryComponentsToReid3x3x3, twizzleBinaryToBinaryComponents } from "../chunks/chunk-O3WM5PPB.js"; import { puzzles } from "../chunks/chunk-LYOTMOJT.js"; import { cube3x3x3, experimental3x3x3KPuzzle } from "../chunks/chunk-SMJZNAUN.js"; import { KPattern } from "../chunks/chunk-TMCMUPQG.js"; import { Alg, Move, experimentalAppendMove, keyToMove } from "../chunks/chunk-QVWFSWHJ.js"; // src/cubing/bluetooth/debug.ts var DEBUG_LOGGING_ENABLED = false; function enableDebugLogging(enable) { DEBUG_LOGGING_ENABLED = enable; } function debugLog(...args) { if (!DEBUG_LOGGING_ENABLED) { return; } if (console.info) { console.info(...args); } else { console.log(...args); } } // src/cubing/bluetooth/transformer.ts import { Quaternion } from "three/src/math/Quaternion.js"; import { Vector3 } from "three/src/math/Vector3.js"; function maxAxis(v) { const maxVal = Math.max(Math.abs(v.x), Math.abs(v.y), Math.abs(v.z)); switch (maxVal) { case v.x: return "x"; case -v.x: return "-x"; case v.y: return "y"; case -v.y: return "-y"; case v.z: return "z"; case -v.z: return "-z"; default: throw new Error("Uh-oh."); } } var s2 = Math.sqrt(0.5); var m = { "y z": new Quaternion(0, 0, 0, 1), "-z y": new Quaternion(s2, 0, 0, s2), "x z": new Quaternion(0, 0, -s2, s2), "-x z": new Quaternion(0, 0, s2, s2) }; var BasicRotationTransformer = class { // private reorientQuat = new Quaternion(); transformAlgLeaf(_algLeafEvent) { } transformOrientation(orientationEvent) { const { x, y, z, w } = orientationEvent.quaternion; const quat = new Quaternion(x, y, z, w); const U = new Vector3(0, 1, 0); const F = new Vector3(0, 0, 1); const maxU = maxAxis(U.applyQuaternion(quat)); const maxF = maxAxis(F.applyQuaternion(quat)); const oriQuat = m[`${maxU} ${maxF}`] || m["y z"]; console.log(quat); console.log(oriQuat); const q2 = quat.premultiply(oriQuat); console.log(q2); orientationEvent.quaternion = quat; console.log(orientationEvent.quaternion); } }; // src/cubing/bluetooth/smart-puzzle/bluetooth-puzzle.ts var BluetoothPuzzle = class extends EventTarget { transformers = []; listeners = []; // TODO: type orientationListeners = []; // TODO: Can we make this reutrn (async) on success? // TODO: require subclasses to implement this? async getPattern() { throw new Error("cannot get pattern"); } addAlgLeafListener(listener) { this.listeners.push(listener); } addOrientationListener(listener) { this.orientationListeners.push(listener); } experimentalAddBasicRotationTransformer() { this.transformers.push(new BasicRotationTransformer()); } dispatchAlgLeaf(algLeaf) { for (const transformer of this.transformers) { transformer.transformAlgLeaf(algLeaf); } for (const l of this.listeners) { l(algLeaf); } } dispatchOrientation(orientationEvent) { for (const transformer of this.transformers) { transformer.transformOrientation(orientationEvent); } const { x, y, z, w } = orientationEvent.quaternion; orientationEvent.quaternion = { x, y, z, w }; for (const l of this.orientationListeners) { l(orientationEvent); } } }; // src/cubing/bluetooth/keyboard.ts var KeyboardPuzzle = class extends BluetoothPuzzle { // (e: KeyboardEvent) => Promise<void>; // TODO: Decide on the right arguments. // TODO: support tying the puzzle to a TwistyPlayer. constructor(target, puzzle = "3x3x3") { super(); this.target = target; this.listener = this.onKeyDown.bind(this); target.addEventListener("keydown", this.listener); this.keyMappingAndPatternPromise = this.setPuzzleInternal(puzzle); } keyMappingAndPatternPromise; listener; name() { return "Keyboard Input"; } async setPuzzleInternal(puzzle) { const puzzleLoader = await (async () => typeof puzzle === "string" ? (await import("../puzzles/index.js")).puzzles[puzzle] : puzzle)(); const kpuzzle = await (async () => puzzleLoader.kpuzzle())(); return Promise.all([puzzleLoader.keyMapping?.(), kpuzzle.defaultPattern()]); } disconnect() { this.target.removeEventListener("keydown", this.listener); } async getPattern() { return (await this.keyMappingAndPatternPromise)[1]; } async onKeyDown(e) { if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) { return; } this.keyMappingAndPatternPromise = (async () => { const [keyMapping, pattern] = await this.keyMappingAndPatternPromise; const algLeaf = keyToMove(keyMapping, e); let newPattern; if (algLeaf) { newPattern = pattern.applyAlg(new Alg([algLeaf])); this.dispatchAlgLeaf({ latestAlgLeaf: algLeaf, timeStamp: e.timeStamp, pattern: newPattern }); e.preventDefault(); } return [keyMapping, newPattern ?? pattern]; })(); } }; async function debugKeyboardConnect(target = globalThis, puzzle = "3x3x3") { return new KeyboardPuzzle(target, puzzle); } // src/cubing/bluetooth/connect/index.ts function requestOptions(configs, acceptAllDevices = false) { const options = acceptAllDevices ? { acceptAllDevices: true, optionalServices: [] } : { filters: [], optionalServices: [] }; for (const config of configs) { if (!acceptAllDevices) { options.filters = options.filters.concat(config.filters); } options.optionalServices = options.optionalServices.concat( config.optionalServices ); } debugLog({ requestOptions: options }); return options; } var consecutiveFailures = 0; var MAX_FAILURES_BEFORE_ACCEPT_ALL_FALLBACK = 2; async function bluetoothConnect(configs, options = {}) { debugLog("Attempting to pair."); let device; try { let acceptAllDevices = options.acceptAllDevices; if (!acceptAllDevices && consecutiveFailures >= MAX_FAILURES_BEFORE_ACCEPT_ALL_FALLBACK) { console.info( `The last ${MAX_FAILURES_BEFORE_ACCEPT_ALL_FALLBACK} Bluetooth puzzle connection attempts failed. This time, the Bluetooth prompt will show all possible devices.` ); acceptAllDevices = true; } device = await navigator.bluetooth.requestDevice( requestOptions(configs, acceptAllDevices) ); consecutiveFailures = 0; } catch (e) { consecutiveFailures++; throw e; } debugLog("Device:", device); if (typeof device.gatt === "undefined") { return Promise.reject("Device did not have a GATT server."); } const server = await device.gatt.connect(); debugLog("Server:", server); const name = server.device?.name || ""; for (const config of configs) { for (const prefix of config.prefixes) { if (name?.startsWith(prefix)) { return config.connect(server, device); } } } throw Error("Unknown Bluetooth devive."); } // src/cubing/bluetooth/smart-puzzle/gan.ts import { Quaternion as Quaternion2 } from "three/src/math/Quaternion.js"; // src/cubing/vendor/public-domain/unsafe-raw-aes/unsafe-raw-aes.ts var blockSize = 16; var zeros = new Uint8Array(blockSize); var paddingBlockPlaintext = new Uint8Array( new Array(blockSize).fill(blockSize) ); var AES_CBC = "AES-CBC"; async function importKey(keyBytes) { return await crypto.subtle.importKey( "raw", keyBytes, AES_CBC, true, ["encrypt", "decrypt"] ); } async function unsafeEncryptBlockWithIV(key, plaintextBlock, iv) { const cryptoResult = await globalThis.crypto.subtle.encrypt( { name: AES_CBC, iv }, key, plaintextBlock ); return cryptoResult.slice(0, blockSize); } async function unsafeEncryptBlock(key, plaintextBlock) { return (await unsafeEncryptBlockWithIV(key, plaintextBlock, zeros)).slice( 0, blockSize ); } async function unsafeDecryptBlock(key, ciphertextBlock) { const paddingBlock = await unsafeEncryptBlockWithIV( key, paddingBlockPlaintext, ciphertextBlock ); const cbcCiphertext = new Uint8Array(2 * blockSize); cbcCiphertext.set(new Uint8Array(ciphertextBlock), 0); cbcCiphertext.set(new Uint8Array(paddingBlock), blockSize); const cryptoResult = await globalThis.crypto.subtle.decrypt( { name: AES_CBC, iv: zeros }, key, cbcCiphertext ); return cryptoResult.slice(0, blockSize); } // src/cubing/bluetooth/smart-puzzle/common.ts var reidEdgeOrder = "UF UR UB UL DF DR DB DL FR FL BR BL".split(" "); var reidCornerOrder = "UFR URB UBL ULF DRF DFL DLB DBR".split(" "); function rotateLeft(s, i) { return s.slice(i) + s.slice(0, i); } var pieceMap = {}; reidEdgeOrder.forEach((edge, idx) => { for (let i = 0; i < 2; i++) { pieceMap[rotateLeft(edge, i)] = { piece: idx, orientation: i }; } }); reidCornerOrder.forEach((corner, idx) => { for (let i = 0; i < 3; i++) { pieceMap[rotateLeft(corner, i)] = { piece: idx, orientation: i }; } }); function getPatternData(stickers, faceOrder3, edgeMappings, cornerMappings) { const patternData = { CORNERS: { pieces: [], orientation: [] }, EDGES: { pieces: [], orientation: [] }, CENTERS: { pieces: [0, 1, 2, 3, 4, 5], orientation: [0, 0, 0, 0, 0, 0], orientationMod: [1, 1, 1, 1, 1, 1] } }; for (const cornerMapping of cornerMappings) { const pieceInfo = pieceMap[cornerMapping.map((i) => faceOrder3[stickers[i]]).join("")]; patternData["CORNERS"].pieces.push(pieceInfo.piece); patternData["CORNERS"].orientation.push(pieceInfo.orientation); } for (const edgeMapping of edgeMappings) { const pieceInfo = pieceMap[edgeMapping.map((i) => faceOrder3[stickers[i]]).join("")]; patternData["EDGES"].pieces.push(pieceInfo.piece); patternData["EDGES"].orientation.push(pieceInfo.orientation); } return patternData; } // src/cubing/bluetooth/smart-puzzle/gan.ts var DEFAULT_INTERVAL_MS = 150; var MAX_LATEST_MOVES = 6; var ganMoveToBlockMove = { 0: new Move("U"), 2: new Move("U", -1), 3: new Move("R"), 5: new Move("R", -1), 6: new Move("F"), 8: new Move("F", -1), 9: new Move("D"), 11: new Move("D", -1), 12: new Move("L"), 14: new Move("L", -1), 15: new Move("B"), 17: new Move("B", -1) }; var homeQuatInverse = null; function probablyDecodedCorrectly(data) { return data[13] < 18 && data[14] < 18 && data[15] < 18 && data[16] < 18 && data[17] < 18 && data[18] < 18; } var key10 = new Uint8Array([ 198, 202, 21, 223, 79, 110, 19, 182, 119, 13, 230, 89, 58, 175, 186, 162 ]); var key11 = new Uint8Array([ 67, 226, 91, 214, 125, 220, 120, 216, 7, 96, 163, 218, 130, 60, 1, 241 ]); async function decryptPattern(data, aesKey) { if (aesKey === null) { return data; } const copy = new Uint8Array(data); copy.set(new Uint8Array(await unsafeDecryptBlock(aesKey, copy.slice(3))), 3); copy.set( new Uint8Array(await unsafeDecryptBlock(aesKey, copy.slice(0, 16))), 0 ); if (probablyDecodedCorrectly(copy)) { return copy; } throw new Error("Invalid Gan cube pattern"); } var PhysicalState = class _PhysicalState { constructor(dataView, timeStamp) { this.dataView = dataView; this.timeStamp = timeStamp; this.arr = new Uint8Array(dataView.buffer); if (this.arr.length !== this.arrLen) { throw new Error("Unexpected array length"); } } static async read(characteristic, aesKey) { const value = await decryptPattern( new Uint8Array((await characteristic.readValue()).buffer), aesKey ); const timeStamp = Date.now(); return new _PhysicalState(new DataView(value.buffer), timeStamp); } arr; arrLen = 19; rotQuat() { let x = this.dataView.getInt16(0, true) / 16384; let y = this.dataView.getInt16(2, true) / 16384; let z = this.dataView.getInt16(4, true) / 16384; [x, y, z] = [-y, z, -x]; const wSquared = 1 - (x * x + y * y + z * z); const w = wSquared > 0 ? Math.sqrt(wSquared) : 0; const quat = new Quaternion2(x, y, z, w); if (!homeQuatInverse) { homeQuatInverse = quat.clone().invert(); } return quat.clone().multiply(homeQuatInverse.clone()); } // Loops from 255 to 0. moveCounter() { return this.arr[12]; } numMovesSince(previousMoveCounter) { return this.moveCounter() - previousMoveCounter & 255; } // Due to the design of the Gan356i protocol, it's common to query for the // latest physical state and find 0 moves have been performed since the last // query. Therefore, it's useful to allow 0 as an argument. latestMoves(n) { if (n < 0 || n > MAX_LATEST_MOVES) { throw new Error(`Must ask for 0 to 6 latest moves. (Asked for ${n})`); } return Array.from(this.arr.slice(19 - n, 19)).map( (i) => ganMoveToBlockMove[i] ); } debugInfo() { return { arr: this.arr }; } }; var UUIDs = { ganCubeService: "0000fff0-0000-1000-8000-00805f9b34fb", physicalStateCharacteristic: "0000fff5-0000-1000-8000-00805f9b34fb", actualAngleAndBatteryCharacteristic: "0000fff7-0000-1000-8000-00805f9b34fb", faceletStatus1Characteristic: "0000fff2-0000-1000-8000-00805f9b34fb", faceletStatus2Characteristic: "0000fff3-0000-1000-8000-00805f9b34fb", infoService: "0000180a-0000-1000-8000-00805f9b34fb", systemIDCharacteristic: "00002a23-0000-1000-8000-00805f9b34fb", versionCharacteristic: "00002a28-0000-1000-8000-00805f9b34fb" }; var commands = { reset: new Uint8Array([ 0, 0, 36, 0, 73, 146, 36, 73, 109, 146, 219, 182, 73, 146, 182, 36, 109, 219 ]) }; function buf2hex(buffer) { return Array.prototype.map.call( new Uint8Array(buffer), (x) => `00${x.toString(16)}`.slice(-2) ).join(" "); } var gan356iCornerMappings = [ [0, 21, 15], [5, 13, 47], [7, 45, 39], [2, 37, 23], [29, 10, 16], [31, 18, 32], [26, 34, 40], [24, 42, 8] ]; var gan356iEdgeMappings = [ [1, 22], [3, 14], [6, 46], [4, 38], [30, 17], [27, 9], [25, 41], [28, 33], [19, 12], [20, 35], [44, 11], [43, 36] ]; var faceOrder = "URFDLB"; async function getKey(server) { const infoService = await server.getPrimaryService(UUIDs.infoService); const versionCharacteristic = await infoService.getCharacteristic( UUIDs.versionCharacteristic ); const versionBuffer = new Uint8Array( (await versionCharacteristic.readValue()).buffer ); const versionValue = ((versionBuffer[0] << 8) + versionBuffer[1] << 8) + versionBuffer[2]; if (versionValue < 65544) { return null; } const keyXor = versionValue < 65792 ? key10 : key11; const systemIDCharacteristic = await infoService.getCharacteristic( UUIDs.systemIDCharacteristic ); const systemID = new Uint8Array( (await systemIDCharacteristic.readValue()).buffer ).reverse(); const key = new Uint8Array(keyXor); for (let i = 0; i < systemID.length; i++) { key[i] = (key[i] + systemID[i]) % 256; } return importKey(key); } var GanCube = class _GanCube extends BluetoothPuzzle { constructor(kpuzzle, service, server, physicalStateCharacteristic, lastMoveCounter, aesKey) { super(); this.kpuzzle = kpuzzle; this.service = service; this.server = server; this.physicalStateCharacteristic = physicalStateCharacteristic; this.lastMoveCounter = lastMoveCounter; this.aesKey = aesKey; this.pattern = kpuzzle.defaultPattern(); this.startTrackingMoves(); } // We have to perform async operations before we call the constructor. static async connect(server) { const ganCubeService = await server.getPrimaryService(UUIDs.ganCubeService); debugLog("Service:", ganCubeService); const physicalStateCharacteristic = await ganCubeService.getCharacteristic( UUIDs.physicalStateCharacteristic ); debugLog("Characteristic:", physicalStateCharacteristic); const aesKey = await getKey(server); const initialMoveCounter = (await PhysicalState.read(physicalStateCharacteristic, aesKey)).moveCounter(); debugLog("Initial Move Counter:", initialMoveCounter); const cube = new _GanCube( await puzzles["3x3x3"].kpuzzle(), ganCubeService, server, physicalStateCharacteristic, initialMoveCounter, aesKey ); return cube; } INTERVAL_MS = DEFAULT_INTERVAL_MS; intervalHandle = null; pattern; cachedFaceletStatus1Characteristic; cachedFaceletStatus2Characteristic; cachedActualAngleAndBatteryCharacteristic; name() { return this.server.device.name; } disconnect() { this.server.disconnect(); } startTrackingMoves() { this.intervalHandle = window.setInterval( this.intervalHandler.bind(this), this.INTERVAL_MS ); } stopTrackingMoves() { if (!this.intervalHandle) { throw new Error("Not tracking moves!"); } clearInterval(this.intervalHandle); this.intervalHandle = null; } // TODO: Can we ever receive async responses out of order? async intervalHandler() { const physicalState = await PhysicalState.read( this.physicalStateCharacteristic, this.aesKey ); let numInterveningMoves = physicalState.numMovesSince(this.lastMoveCounter); if (numInterveningMoves > MAX_LATEST_MOVES) { debugLog( `Too many moves! Dropping ${numInterveningMoves - MAX_LATEST_MOVES} moves` ); numInterveningMoves = MAX_LATEST_MOVES; } for (const move of physicalState.latestMoves(numInterveningMoves)) { this.pattern = this.pattern.applyMove(move); this.dispatchAlgLeaf({ latestAlgLeaf: move, timeStamp: physicalState.timeStamp, debug: physicalState.debugInfo(), pattern: this.pattern // quaternion: physicalState.rotQuat(), }); } this.dispatchOrientation({ timeStamp: physicalState.timeStamp, quaternion: physicalState.rotQuat() }); this.lastMoveCounter = physicalState.moveCounter(); } async getBattery() { return new Uint8Array( await this.readActualAngleAndBatteryCharacteristic() )[7]; } async getPattern() { const arr = await decryptPattern( new Uint8Array(await this.readFaceletStatus1Characteristic()), this.aesKey ); const stickers = []; for (let i = 0; i < 18; i += 3) { let v = ((arr[i ^ 1] << 8) + arr[i + 1 ^ 1] << 8) + arr[i + 2 ^ 1]; for (let j = 0; j < 8; j++) { stickers.push(v & 7); v >>= 3; } } return new KPattern( this.kpuzzle, getPatternData( stickers, faceOrder, gan356iEdgeMappings, gan356iCornerMappings ) ); } async faceletStatus1Characteristic() { this.cachedFaceletStatus1Characteristic = this.cachedFaceletStatus1Characteristic || this.service.getCharacteristic(UUIDs.faceletStatus1Characteristic); return this.cachedFaceletStatus1Characteristic; } async faceletStatus2Characteristic() { this.cachedFaceletStatus2Characteristic = this.cachedFaceletStatus2Characteristic || this.service.getCharacteristic(UUIDs.faceletStatus2Characteristic); return this.cachedFaceletStatus2Characteristic; } async actualAngleAndBatteryCharacteristic() { this.cachedActualAngleAndBatteryCharacteristic = this.cachedActualAngleAndBatteryCharacteristic || this.service.getCharacteristic(UUIDs.actualAngleAndBatteryCharacteristic); return this.cachedActualAngleAndBatteryCharacteristic; } async reset() { const faceletStatus1Characteristic = await this.faceletStatus1Characteristic(); await faceletStatus1Characteristic.writeValue(commands["reset"]); } async readFaceletStatus1Characteristic() { const faceletStatus1Characteristic = await this.faceletStatus1Characteristic(); return (await faceletStatus1Characteristic.readValue()).buffer; } async readFaceletStatus2Characteristic() { const faceletStatus2Characteristic = await this.faceletStatus2Characteristic(); return buf2hex((await faceletStatus2Characteristic.readValue()).buffer); } async readActualAngleAndBatteryCharacteristic() { const actualAngleAndBatteryCharacteristic = await this.actualAngleAndBatteryCharacteristic(); return (await actualAngleAndBatteryCharacteristic.readValue()).buffer; } // TODO // private onphysicalStateCharacteristicChanged(event: any): void { // var val = event.target.value; // debugLog(val); // } }; var ganConfig = { connect: GanCube.connect.bind(GanCube), prefixes: ["GAN"], filters: [{ namePrefix: "GAN" }], optionalServices: [UUIDs.ganCubeService, UUIDs.infoService] }; // src/cubing/bluetooth/smart-puzzle/giiker.ts var MESSAGE_LENGTH = 20; var UUIDs2 = { cubeService: "0000aadb-0000-1000-8000-00805f9b34fb", cubeCharacteristic: "0000aadc-0000-1000-8000-00805f9b34fb" }; function giikerMoveToAlgMove(face, amount) { switch (amount) { case 3: { amount = -1; break; } case 9: { debugLog("Encountered 9", face, amount); amount = -2; break; } } const family = ["?", "B", "D", "L", "U", "R", "F"][face]; return new Move(family, amount); } function giikerStateStr(giikerState) { let str = ""; str += giikerState.slice(0, 8).join("."); str += "\n"; str += giikerState.slice(8, 16).join("."); str += "\n"; str += giikerState.slice(16, 28).join("."); str += "\n"; str += giikerState.slice(28, 32).join("."); str += "\n"; str += giikerState.slice(32, 40).join("."); return str; } var Reid333SolvedCenters = { pieces: [0, 1, 2, 3, 4, 5], orientation: [0, 0, 0, 0, 0, 0], orientationMod: [1, 1, 1, 1, 1, 1] // TODO }; var epGiiKERtoReid333 = [4, 8, 0, 9, 5, 1, 3, 7, 6, 10, 2, 11]; var epReid333toGiiKER = [2, 5, 10, 6, 0, 4, 8, 7, 1, 3, 9, 11]; var preEO = [1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0]; var postEO = [1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0]; var cpGiiKERtoReid333 = [4, 0, 3, 5, 7, 1, 2, 6]; var cpReid333toGiiKER = [1, 5, 6, 2, 0, 3, 7, 4]; var preCO = [1, 2, 1, 2, 2, 1, 2, 1]; var postCO = [2, 1, 2, 1, 1, 2, 1, 2]; var coFlip = [-1, 1, -1, 1, 1, -1, 1, -1]; function getNibble(val, i) { if (i % 2 === 1) { return val[i / 2 | 0] % 16; } return 0 | val[i / 2 | 0] / 16; } function probablyEncrypted(data) { return data[18] === 167; } var lookup = [ 176, 81, 104, 224, 86, 137, 237, 119, 38, 26, 193, 161, 210, 126, 150, 81, 93, 13, 236, 249, 89, 235, 88, 24, 113, 81, 214, 131, 130, 199, 2, 169, 39, 165, 171, 41 ]; function decryptState(data) { const offset1 = getNibble(data, 38); const offset2 = getNibble(data, 39); const output = new Uint8Array(MESSAGE_LENGTH); for (let i = 0; i < MESSAGE_LENGTH; i++) { output[i] = data[i] + lookup[offset1 + i] + lookup[offset2 + i]; } return output; } async function decodeState(data) { if (!probablyEncrypted(data)) { return data; } return decryptState(data); } var GiiKERCube = class _GiiKERCube extends BluetoothPuzzle { constructor(server, cubeCharacteristic, originalValue) { super(); this.server = server; this.cubeCharacteristic = cubeCharacteristic; this.originalValue = originalValue; } static async connect(server) { const cubeService = await server.getPrimaryService(UUIDs2.cubeService); debugLog("Service:", cubeService); const cubeCharacteristic = await cubeService.getCharacteristic( UUIDs2.cubeCharacteristic ); debugLog("Characteristic:", cubeCharacteristic); const originalValue = await decodeState( new Uint8Array((await cubeCharacteristic.readValue()).buffer) ); debugLog("Original value:", originalValue); const cube = new _GiiKERCube(server, cubeCharacteristic, originalValue); await cubeCharacteristic.startNotifications(); cubeCharacteristic.addEventListener( "characteristicvaluechanged", cube.onCubeCharacteristicChanged.bind(cube) ); return cube; } name() { return this.server.device.name; } disconnect() { this.server.disconnect(); } async getPattern() { return this.toReid333( new Uint8Array((await this.cubeCharacteristic.readValue()).buffer) ); } getBit(val, i) { const n = i / 8 | 0; const shift = 7 - i % 8; return val[n] >> shift & 1; } toReid333(val) { const patternData = { EDGES: { pieces: new Array(12), orientation: new Array(12) }, CORNERS: { pieces: new Array(8), orientation: new Array(8) }, CENTERS: Reid333SolvedCenters }; for (let i = 0; i < 12; i++) { const gi = epReid333toGiiKER[i]; patternData["EDGES"].pieces[i] = epGiiKERtoReid333[getNibble(val, gi + 16) - 1]; patternData["EDGES"].orientation[i] = this.getBit(val, gi + 112) ^ preEO[patternData["EDGES"].pieces[i]] ^ postEO[i]; } for (let i = 0; i < 8; i++) { const gi = cpReid333toGiiKER[i]; patternData["CORNERS"].pieces[i] = cpGiiKERtoReid333[getNibble(val, gi) - 1]; patternData["CORNERS"].orientation[i] = (getNibble(val, gi + 8) * coFlip[gi] + preCO[patternData["CORNERS"].pieces[i]] + postCO[i]) % 3; } return new KPattern(experimental3x3x3KPuzzle, patternData); } async onCubeCharacteristicChanged(event) { const val = await decodeState(new Uint8Array(event.target.value.buffer)); debugLog(val); debugLog(val); if (this.isRepeatedInitialValue(val)) { debugLog("Skipping repeated initial value."); return; } const giikerState = []; for (let i = 0; i < MESSAGE_LENGTH; i++) { giikerState.push(Math.floor(val[i] / 16)); giikerState.push(val[i] % 16); } debugLog(giikerState); const str = giikerStateStr(giikerState); debugLog(str); this.dispatchAlgLeaf({ latestAlgLeaf: giikerMoveToAlgMove(giikerState[32], giikerState[33]), timeStamp: event.timeStamp, debug: { stateStr: str }, pattern: this.toReid333(val) }); } isRepeatedInitialValue(val) { if (typeof this.originalValue === "undefined") { throw new Error("GiiKERCube has uninitialized original value."); } if (this.originalValue === null) { return false; } const originalValue = this.originalValue; this.originalValue = null; debugLog("Comparing against original value."); for (let i = 0; i < MESSAGE_LENGTH - 2; i++) { if (originalValue[i] !== val[i]) { debugLog("Different at index ", i); return false; } } return true; } }; var giiKERConfig = { connect: GiiKERCube.connect.bind(GiiKERCube), prefixes: ["Gi", "", "Mi", "Hi-"], // Hack filters: [ // Known prefixes: GiC, GiS (3x3x3), Gi2 (2x2x2) // Suspected prefixes GiY, Gi3 { namePrefix: "Gi" }, { namePrefix: "Mi" }, { namePrefix: "Hi-" }, { services: ["0000aadb-0000-1000-8000-00805f9b34fb"] }, { services: ["0000aaaa-0000-1000-8000-00805f9b34fb"] }, { services: ["0000fe95-0000-1000-8000-00805f9b34fb"] } ], optionalServices: [ // "00001530-1212-efde-1523-785feabcd123", // "0000aaaa-0000-1000-8000-00805f9b34fb", UUIDs2.cubeService // "0000180f-0000-1000-8000-00805f9b34fb", // "0000180a-0000-1000-8000-00805f9b34fb" ] }; // src/cubing/bluetooth/smart-puzzle/gocube.ts import { Quaternion as Quaternion3 } from "three/src/math/Quaternion.js"; var UUIDs3 = { goCubeService: "6e400001-b5a3-f393-e0a9-e50e24dcca9e", goCubeStateCharacteristic: "6e400003-b5a3-f393-e0a9-e50e24dcca9e" }; function buf2hex2(buffer) { return Array.prototype.map.call( new Uint8Array(buffer), (x) => `00${x.toString(16)}`.slice(-2) ).join(" "); } function bufferToString(buffer) { const byteView = new Uint8Array(buffer); let str = ""; for (const charCode of byteView) { str += String.fromCharCode(charCode); } return str; } var moveMap = [ new Move("B", 1), new Move("B", -1), new Move("F", 1), new Move("F", -1), new Move("U", 1), new Move("U", -1), new Move("D", 1), new Move("D", -1), new Move("R", 1), new Move("R", -1), new Move("L", 1), new Move("L", -1) ]; var GoCube = class _GoCube extends BluetoothPuzzle { constructor(server, goCubeStateCharacteristic) { super(); this.server = server; this.goCubeStateCharacteristic = goCubeStateCharacteristic; } // We have to perform async operations before we call the constructor. static async connect(server) { const service = await server.getPrimaryService(UUIDs3.goCubeService); debugLog({ service }); const goCubeStateCharacteristic = await service.getCharacteristic( UUIDs3.goCubeStateCharacteristic ); debugLog({ goCubeStateCharacteristic }); const cube = new _GoCube(server, goCubeStateCharacteristic); await goCubeStateCharacteristic.startNotifications(); goCubeStateCharacteristic.addEventListener( "characteristicvaluechanged", cube.onCubeCharacteristicChanged.bind(cube) ); return cube; } // public async getState(): Promise<PuzzleState> { // return new Promise((resolve, reject) => { // this.resolve = (value: any) => { // resolve(buf2hex(value.buffer) as any); // }; // this.goCubeStateCharacteristic.startNotifications(); // }); // } recorded = []; homeQuatInverse = null; lastRawQuat = new Quaternion3(0, 0, 0, 1); currentQuat = new Quaternion3(0, 0, 0, 1); lastTarget = new Quaternion3(0, 0, 0, 1); alg = new Alg(); disconnect() { this.server.disconnect(); } reset() { this.resetAlg(); this.resetOrientation(); } resetAlg(alg) { this.alg = alg || new Alg(); } resetOrientation() { this.homeQuatInverse = this.lastRawQuat.clone().invert(); this.currentQuat = new Quaternion3(0, 0, 0, 1); this.lastTarget = new Quaternion3(0, 0, 0, 1); } name() { return this.server.device.name; } onCubeCharacteristicChanged(event) { const buffer = event.target.value; this.recorded.push([event.timeStamp, buf2hex2(buffer.buffer)]); if (buffer.byteLength < 16) { for (let i = 3; i < buffer.byteLength - 4; i += 2) { const move = moveMap[buffer.getUint8(i)]; this.alg = experimentalAppendMove(this.alg, move); this.dispatchAlgLeaf({ latestAlgLeaf: moveMap[buffer.getUint8(i)], timeStamp: event.timeStamp, debug: { stateStr: buf2hex2(buffer.buffer) } }); } } else { const coords = bufferToString( buffer.buffer.slice(3, buffer.byteLength - 3) ).split("#").map((s) => parseInt(s, 10) / 16384); const quat = new Quaternion3(coords[0], coords[1], coords[2], coords[3]); this.lastRawQuat = quat.clone(); if (!this.homeQuatInverse) { this.homeQuatInverse = quat.clone().invert(); } const targetQuat = quat.clone().multiply(this.homeQuatInverse.clone()); targetQuat.y = -targetQuat.y; this.lastTarget.slerp(targetQuat, 0.5); this.currentQuat.rotateTowards(this.lastTarget, rotateTowardsRate); this.dispatchOrientation({ quaternion: this.currentQuat, timeStamp: event.timeStamp }); } } }; var rotateTowardsRate = 0.5; var goCubeConfig = { connect: GoCube.connect.bind(GoCube), prefixes: ["GoCube", "Rubik"], filters: [{ namePrefix: "GoCube" }, { namePrefix: "Rubik" }], optionalServices: [UUIDs3.goCubeService] }; // src/cubing/bluetooth/smart-puzzle/endianness.ts function flipBitOrder(v, numBits) { let result = 0; for (let i = 0; i < numBits; i++) { const shiftLeft = numBits - 1 - 2 * i; const unShiftedBit = v & 1 << i; result += shiftLeft < 0 ? unShiftedBit >> -shiftLeft : unShiftedBit << shiftLeft; } return result; } // src/cubing/bluetooth/smart-puzzle/Heykube.ts var UUIDs4 = { heykubeService: "b46a791a-8273-4fc1-9e67-94d3dc2aac1c", stateCharacteristic: "a2f41a4e-0e31-4bbc-9389-4253475481fb", batteryCharacteristic: "fd51b3ba-99c7-49c6-9f85-5644ff56a378" }; var HeykubeCube = class _HeykubeCube extends BluetoothPuzzle { constructor(_kpuzzle, _service, device, server, stateCharacteristic) { super(); this.server = server; this.stateCharacteristic = stateCharacteristic; device.addEventListener( "gattserverdisconnected", this.onDisconnect.bind(this) ); this.stateCharacteristic.startNotifications(); this.startTrackingMoves(); } // We have to perform async operations before we call the constructor. static async connect(server, device) { const service = await server.getPrimaryService(UUIDs4.heykubeService); debugLog("Service:", service); const stateCharacteristic = await service.getCharacteristic( UUIDs4.stateCharacteristic ); debugLog("Characteristic:", stateCharacteristic); const cube = new _HeykubeCube( await puzzles["3x3x3"].kpuzzle(), service, device, server, stateCharacteristic ); return cube; } name() { return this.server.device.name; } disconnect() { this.server.disconnect(); } onDisconnect() { this.dispatchEvent(new CustomEvent("disconnect")); } startTrackingMoves() { this.stateCharacteristic.addEventListener( "characteristicvaluechanged", (e) => this.onStateCharacteristic(e) ); } // public stopTrackingMoves(): void {} // public async getBattery(): Promise<number> { // return new Uint8Array( // await this.readActualAngleAndBatteryCharacteristic(), // )[7]; // }srcElement: BluetoothRemoteGATTCharacteristic onStateCharacteristic(event) { const state = this.decodeState(event.target.value); this.dispatchAlgLeaf({ latestAlgLeaf: state.latestMove, timeStamp: event.timeStamp, pattern: state.pattern }); } decodeState(dv) { const moves = [ new Move("U"), new Move("U'"), new Move("B"), new Move("B'"), new Move("F"), new Move("F'"), null, null, new Move("L"), new Move("L'"), new Move("D"), new Move("D'"), new Move("R"), new Move("R'") // null, // null, ]; const b2 = new Uint8Array(dv.byteLength); for (let i = 0; i < dv.byteLength; i++) { b2[i] = flipBitOrder(dv.getUint8(i), 8); } const components1 = twizzleBinaryToBinaryComponents( b2.slice(0, 11) ); const components2 = { epLex: flipBitOrder(components1.epLex, 29), eoMask: flipBitOrder(components1.eoMask, 12), cpLex: flipBitOrder(components1.cpLex, 16), coMask: flipBitOrder(components1.coMask, 13), poIdxL: 0, poIdxU: 7, moSupport: 1, // TODO moMask: 0 }; return { pattern: binaryComponentsToReid3x3x3(components2), latestMove: moves[b2[20] & 15] }; } async getPattern() { const b1 = await this.stateCharacteristic.readValue(); return this.decodeState(b1).pattern; } }; var heykubeConfig = { connect: HeykubeCube.connect.bind(HeykubeCube), prefixes: ["HEYKUBE"], filters: [{ namePrefix: "HEYKUBE" }], optionalServices: [UUIDs4.heykubeService] }; // src/cubing/bluetooth/smart-puzzle/qiyi.ts var UUIDs5 = { qiyiMainService: 65520, qiyiMainCharacteristic: 65526 }; var qiyiMoveToBlockMove = { 1: new Move("L", -1), 2: new Move("L"), 3: new Move("R", -1), 4: new Move("R"), 5: new Move("D", -1), 6: new Move("D"), 7: new Move("U", -1), 8: new Move("U"), 9: new Move("F", -1), 10: new Move("F"), 11: new Move("B", -1), 12: new Move("B") }; var faceOrder2 = "LRDUFB"; var qiyiCornerMappings = [ [8, 20, 9], // UFR, [2, 11, 45], // URB, [0, 47, 36], // UBL, [6, 38, 18], // ULF, [29, 15, 26], // DRF, [27, 24, 44], // DFL, [33, 42, 53], // DLB, [35, 51, 17] // DBR, ]; var qiyiEdgeMappings = [ [7, 19], // UF, [5, 10], // UR, [1, 46], // UB, [3, 37], // UL, [28, 25], // DF, [32, 16], // DR, [34, 52], // DB, [30, 43], // DL, [23, 12], // FR, [21, 41], // FL, [48, 14], // BR, [50, 39] // BL, ]; function generateChecksum(data) { const TABLE = new Uint16Array([ 0, 49345, 49537, 320, 49921, 960, 640, 49729, 50689, 1728, 1920, 51009, 1280, 50625, 50305, 1088, 52225, 3264, 3456, 52545, 3840, 53185, 52865, 3648, 2560, 51905, 52097, 2880, 51457, 2496, 2176, 51265, 55297, 6336, 6528, 55617, 6912, 56257, 55937, 6720, 7680, 57025, 57217, 8e3, 56577, 7616, 7296, 56385, 5120, 54465, 54657, 5440, 55041, 6080, 5760, 54849, 53761, 4800, 4992, 54081, 4352, 53697, 53377, 4160, 61441, 12480, 12672, 61761, 13056, 62401, 62081, 12864, 13824, 63169, 63361, 14144, 62721, 13760, 13440, 62529, 15360, 64705, 64897, 15680, 65281, 16320, 16e3, 65089, 64001, 15040, 15232, 64321, 14592, 63937, 63617, 14400, 10240, 59585, 59777, 10560, 60161, 11200, 10880, 59969, 60929, 11968, 12160, 61249, 11520, 60865, 60545, 11328, 58369, 9408, 9600, 58689, 9984, 59329, 59009, 9792, 8704, 58049, 58241, 9024, 57601, 8640, 8320, 57409, 40961, 24768, 24960, 41281, 25344, 41921, 41601, 25152, 26112, 42689, 42881, 26432, 42241, 26048, 25728, 42049, 27648, 44225, 44417, 27968, 44801, 28608, 28288, 44609, 43521, 27328, 27520, 43841, 26880, 43457, 43137, 26688, 30720, 47297, 47489, 31040, 47873, 31680, 31360, 47681, 48641, 32448, 32640, 48961, 32e3, 48577, 48257, 31808, 46081, 29888, 30080, 46401, 30464, 47041, 46721, 30272, 29184, 45761, 45953, 29504, 45313, 29120, 28800, 45121, 20480, 37057, 37249, 20800, 37633, 21440, 21120, 37441, 38401, 22208, 22400, 38721, 21760, 38337, 38017, 21568, 39937, 23744, 23936, 40257, 24320, 40897, 40577, 24128, 23040, 39617, 39809, 23360, 39169, 22976, 22656, 38977, 34817, 18624, 18816, 35137, 19200, 35777, 35457, 19008, 19968, 36545, 36737, 20288, 36097, 19904, 19584, 35905, 17408, 33985, 34177, 17728, 34561, 18368, 18048, 34369, 33281, 17088, 17280, 33601, 16640, 33217, 32897, 16448 ]); let crc = 65535; for (const dataPoint of data) { const xor = (dataPoint ^ crc) & 255; crc >>= 8; crc ^= TABLE[xor]; } return crc; } async function prepareMessage(message, aesKey) { const messageCopyForChecksum = structuredClone(message); const checksum = generateChecksum(messageCopyForChecksum); messageCopyForChecksum.push(checksum & 255); messageCopyForChecksum.push(checksum >> 8); const paddedLength = Math.ceil(messageCopyForChecksum.length / 16) * 16; const paddedArray = new Uint8Array([ ...messageCopyForChecksum, ...Array(paddedLength - messageCopyForChecksum.length).fill(0) ]); const encryptedMessage = new Uint8Array(paddedLength); for (let i = 0; i < paddedArray.length / 16; i++) { const encryptedBlock = new Uint8Array( await unsafeEncryptBlock(aesKey, paddedArray.slice(i * 16, (i + 1) * 16)) ); encryptedMessage.set(encryptedBlock, i * 16); } return encryptedMessage; } async function decryptMessage(encryptedMessage, aesKey) { const decryptedMessage = new Uint8Array(encryptedMessage.length); for (let i = 0; i < encryptedMessage.length / 16; i++) { const decryptedBlock = new Uint8Array( await unsafeDecryptBlock( aesKey, encryptedMessage.slice(i * 16, (i + 1) * 16) ) ); decryptedMessage.set(decryptedBlock, i * 16); } return decryptedMessage; } function getMacAddress(device) { if (device.name === void 0) { return; } return [ 204, 163, 0, 0, parseInt(device.name.slice(10, 12), 16), parseInt(device.name.slice(12, 14), 16) ]; } var MAX_TIMESTAMP_COUNT = 12; var TIMESTAMP_SCALE = 1.6; var CUBE_HELLO = 2; var STATE_CHANGE = 3; var QiyiCube = class _QiyiCube extends BluetoothPuzzle { constructor(kpuzzle, aesKey, server) { super(); this.kpuzzle = kpuzzle; this.aesKey = aesKey; this.server = server; this.allTimeStamps = /* @__PURE__ */ new Set(); this.allTimeStampsQueue = []; void (async () => { await this.startNotifications(); void this.sendAppHello(); })(); } latestTimestamp; allTimeStamps; // Without this set, moves are constantly duplicated allTimeStampsQueue; stickers = [ 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 5, 5, 5, 5 ]; batteryLevel = 100; static async connect(server) { const aesKey = await importKey( new Uint8Array([ 87, 177, 249, 171, 205, 90, 232, 167, 156, 185, 140, 231, 87, 140, 81, 8 ]) ); return new _QiyiCube(await puzzles["3x3x3"].kpuzzle(), aesKey, server); } async sendAppHello() { const mainService = await this.server.getPrimaryService( UUIDs5.qiyiMainService ); const mainCharacteristic = await mainService.getCharacteristic( UUIDs5.qiyiMainCharacteristic ); for (let macGuessCounter = 0; macGuessCounter < 8; macGuessCounter++) { const mac = getMacAddress(this.server.device); mac[3] = macGuessCounter; const reversedMac = mac.reverse(); const appHello = [ 254, 21, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...reversedMac ]; const appHelloMessage = await prepareMessage(appHello, this.aesKey); await mainCharacteristic.writeValue(appHelloMessage); } } async startNotifications() { const mainService = await this.server.getPrimaryService( UUIDs5.qiyiMainService ); const mainCharacteristic = await mainService.getCharacteristic( UUIDs5.qiyiMainCharacteristic ); mainCharacteristic.addEventListener( "characteristicvaluechanged", this.cubeMessageHandler.bind(this) ); await mainCharacteristic.startNotifications(); } async cubeMessageHandler(event) { const characteristic = event.target; const decryptedMessage = await decryptMessage( new Uint8Array(characteristic.value.buffer), this.aesKey ); const opCode = decryptedMessage[2]; let needsAck = false; switch (opCode) { case CUBE_HELLO: { const initialState = decryptedMessage.slice(7, 34); this.updateState(initialState); this.batteryLevel = decryptedMessage[35]; needsAck = true; break; } case STATE_CHANGE: { const state = decryptedMessage.slice(7, 34); this.updateState(state); const latestMove = qiyiMoveToBlockMove[decryptedMessage[34]]; const latestTimestamp = new DataView( decryptedMessage.slice(3, 7).buffer ).getInt32(0); const moves = [[latestMove, latestTimestamp]]; const previousMoves = new DataView( decryptedMessage.slice(36, 91).buffer ); for (let i = previousMoves.byteLength - 1; i > 0 && previousMoves.getUint8(i) !== 255; i -= 5) { const move = qiyiMoveToBlockMove[previousMoves.getUint8(i)]; const timestamp = previousMoves.getUint32(i - 4); if (this.latestTimestamp === void 0 || timestamp <= this.latestTimestamp) { continue; } moves.push([move, timestamp]); } moves.sort((a, b) => a[1] - b[1]); for (const move of moves) { const latestAlgLeaf = move[0]; const timeStamp = Math.round(move[1] / TIMESTAMP_SCALE); if (!this.allTimeStamps.has(timeStamp)) { this.dispatchAlgLeaf({ latestAlgLeaf, timeStamp }); this.allTimeStamps.add(timeStamp); this.allTimeStampsQueue.push(timeStamp); if (this.allTimeStampsQueue.length > MAX_TIMESTAMP_COUNT) { this.allTimeStamps.delete(this.allTimeStampsQueue.shift()); } } } this.latestTimestamp = latestTimestamp; needsAck = decryptedMessage[91] === 1; break; } default: console.error(`Opcode not implemented: ${opCode}`); break; } if (needsAck) { await characteristic.writeValue( await prepareMessage( [254, 9, ...decryptedMessage.slice(2, 7)], this.aesKey ) ); } } updateState(state) { this.stickers = Array.from(state).flatMap((twoPieces) => [ twoPieces & 15, twoPieces >> 4 ]); } name() { return this.server.device.name; } disconnect() { this.server.disconnect(); } async getPattern() { return new KPattern( this.kpuzzle, getPatternData( this.stickers, faceOrder2, qiyiEdgeMappings, qiyiCornerMappings ) ); } getBattery() { return this.batteryLevel; } }; var qiyiConfig = { connect: QiyiCube.connect.bind(QiyiCube), prefixes: ["QY-QYSC"], filters: [ { namePrefix: "QY-QYSC" } ], optionalServices: [UUIDs5.qiyiMainService] }; // src/cubing/bluetooth/smart-puzzle/connect.ts var smartPuzzleConfigs = [ ganConfig, goCubeConfig, heykubeConfig, qiyiConfig, giiKERConfig // GiiKER must be last, due to Xiaomi naming. TODO: enforce this using tests. ]; async function connectSmartPuzzle(options) { return bluetoothConnect(smartPuzzleConfigs, options); } // src/cubing/bluetooth/smart-robot/GanRobot.ts function buf2hex3(buffer) { return Array.prototype.map.call( new Uint8Array(buffer), (x) => `00${x.toString(16)}`.slice(-2) ).join(" "); } var MAX_NIBBLES_PER_WRITE = 18 * 2; var QUANTUM_TURN_DURATION_MS = 150; var DOUBLE_TURN_DURATION_MS = 250; var U_D_SWAP = new Alg("F B R2 L2 B' F'"); var U_D_UNSWAP = U_D_SWAP.invert(); var F_B_SWAP = new Alg("U D R2 L2 D' U'"); var F_B_UNSWAP = F_B_SWAP.invert(); var UUIDs6 = { ganRobotService: "0000fff0-0000-1000-8000-00805f9b34fb", statusCharacteristic: "0000fff2-0000-1000-8000-00805f9b34fb", moveCharacteristic: "0000fff3-0000-1000-8000-00805f9b34fb" }; var moveMap2 = { R: 0, R2: 1, "R2'": 1, "R'": 2, F: 3, F2: 4, "F2'": 4, "F'": 5, D: 6, D2: 7, "D2'": 7, "D'": 8, L: 9, L2: 10, "L2'": 10, "L'": 11, B: 12, B2: 13, "B2'": 13, "B'": 14 }; var moveMapX = { R: 0, R2: 1, "R2'": 1, "R'": 2, U: 3, U2: 4, "U2'": 4, "U'": 5, F: 6, F2: 7, "F2'": 7, "F'": 8, L: 9, L2: 10, "L2'": 10, "L'": 11, D: 12, D2: 13, "D2'": 13, "D'": 14 }; function isDoubleTurnNibble(nibble) { return nibble % 3 === 1; } function nibbleDuration(nibble) { return isDoubleTurnNibble(nibble) ? DOUBLE_TURN_DURATION_MS : QUANTUM_TURN_DURATION_MS; } function throwInvalidAlgNode(algNode) { console.error("invalid alg node", algNode, algNode.toString()); throw new Error("invalid alg node!"); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } var GanRobot = class _GanRobot extends EventTarget { constructor(_service, server, device, statusCharacteristic, moveCharacteristic) { super(); this.server = server; this.statusCharacteristic = statusCharacteristic; this.moveCharacteristic = moveCharacteristic; device.addEventListener( "gattserverdisconnected", this.onDisconnect.bind(this) ); } experimentalDebugOnSend = null; experimentalDebugLog = () => { }; // Because our Bluetooth connection code is set up not to know what kind of device is connecting, we put these options directly on the class. experimentalOptions = { xAngle: false, singleMoveFixHack: false, bufferQueue: 0, postSleep: 0 }; // We have to perform async operations before we call the constructor. static async connect(server, device) { const ganTimerService = await server.getPrimaryService( UUIDs6.ganRobotService ); const statusCharacteristic = await ganTimerService.getCharacteristic( UUIDs6.statusCharacteristic ); const moveCharacteristic = await ganTimerService.getCharacteristic( UUIDs6.moveCharacteristic ); const timer = new _GanRobot( ganTimerService, server, device, statusCharacteristic, moveCharacteristic ); return timer; } name() { return this.server.device.name; } disconnect() { this.server.disconnect(); } onDisconnect() { this.dispatchEvent(new CustomEvent("disconnect")); } algNodeToNibble(algNode) { const move = algNode.as(Move); if (!move) { throwInvalidAlgNode(algNode); } const nibble = (this.experimentalOptions.xAngle ? moveMapX : moveMap2)[move.toString()] ?? null;