'use strict'; var rxjs = require('rxjs'); var aesJs = require('aes-js'); // GAN Smart Timer bluetooth service and characteristic UUIDs const GAN_TIMER_SERVICE = '0000fff0-0000-1000-8000-00805f9b34fb'; const GAN_TIMER_TIME_CHARACTERISTIC = '0000fff2-0000-1000-8000-00805f9b34fb'; const GAN_TIMER_STATE_CHARACTERISTIC = '0000fff5-0000-1000-8000-00805f9b34fb'; /** * GAN Smart Timer events/states */ exports.GanTimerState = void 0; (function (GanTimerState) { /** Fired when timer is disconnected from bluetooth */ GanTimerState[GanTimerState["DISCONNECT"] = 0] = "DISCONNECT"; /** Grace delay is expired and timer is ready to start */ GanTimerState[GanTimerState["GET_SET"] = 1] = "GET_SET"; /** Hands removed from the timer before grace delay expired */ GanTimerState[GanTimerState["HANDS_OFF"] = 2] = "HANDS_OFF"; /** Timer is running */ GanTimerState[GanTimerState["RUNNING"] = 3] = "RUNNING"; /** Timer is stopped, this event includes recorded time */ GanTimerState[GanTimerState["STOPPED"] = 4] = "STOPPED"; /** Timer is reset and idle */ GanTimerState[GanTimerState["IDLE"] = 5] = "IDLE"; /** Hands are placed on the timer */ GanTimerState[GanTimerState["HANDS_ON"] = 6] = "HANDS_ON"; /** Timer moves to this state immediately after STOPPED */ GanTimerState[GanTimerState["FINISHED"] = 7] = "FINISHED"; })(exports.GanTimerState || (exports.GanTimerState = {})); /** * Construct time object */ function makeTime(min, sec, msec) { return { minutes: min, seconds: sec, milliseconds: msec, asTimestamp: 60000 * min + 1000 * sec + msec, toString: () => `${min.toString(10)}:${sec.toString(10).padStart(2, '0')}.${msec.toString(10).padStart(3, '0')}` }; } /** * Construct time object from raw event data */ function makeTimeFromRaw(data, offset) { var min = data.getUint8(offset); var sec = data.getUint8(offset + 1); var msec = data.getUint16(offset + 2, true); return makeTime(min, sec, msec); } /** * Construct time object from milliseconds timestamp */ function makeTimeFromTimestamp(timestamp) { var min = Math.trunc(timestamp / 60000); var sec = Math.trunc(timestamp % 60000 / 1000); var msec = Math.trunc(timestamp % 1000); return makeTime(min, sec, msec); } /** * Calculate ArrayBuffer checksum using CRC-16/CCIT-FALSE algorithm variation */ function crc16ccit(buff) { var dataView = new DataView(buff); var crc = 0xFFFF; for (let i = 0; i < dataView.byteLength; ++i) { crc ^= dataView.getUint8(i) << 8; for (let j = 0; j < 8; ++j) { crc = (crc & 0x8000) > 0 ? (crc << 1) ^ 0x1021 : crc << 1; } } return crc & 0xFFFF; } /** * Ensure received timer event has valid data: check data magic and CRC */ function validateEventData(data) { try { if (data?.byteLength == 0 || data.getUint8(0) != 0xFE) { return false; } var eventCRC = data.getUint16(data.byteLength - 2, true); var calculatedCRC = crc16ccit(data.buffer.slice(2, data.byteLength - 2)); return eventCRC == calculatedCRC; } catch (err) { return false; } } /** * Construct event object from raw data */ function buildTimerEvent(data) { var evt = { state: data.getUint8(3) }; if (evt.state == exports.GanTimerState.STOPPED) { evt.recordedTime = makeTimeFromRaw(data, 4); } return evt; } /** * Initiate new connection with the GAN Smart Timer device * @returns Connection connection object representing connection API and state */ async function connectGanTimer() { // Request user for the bluetooth device (popup selection dialog) var device = await navigator.bluetooth.requestDevice({ filters: [ { namePrefix: "GAN" }, { namePrefix: "gan" }, { namePrefix: "Gan" } ], optionalServices: [GAN_TIMER_SERVICE] }); // Connect to GATT server var server = await device.gatt.connect(); // Connect to main timer service and characteristics var service = await server.getPrimaryService(GAN_TIMER_SERVICE); var timeCharacteristic = await service.getCharacteristic(GAN_TIMER_TIME_CHARACTERISTIC); var stateCharacteristic = await service.getCharacteristic(GAN_TIMER_STATE_CHARACTERISTIC); // Subscribe to value updates of the timer state characteristic var eventSubject = new rxjs.Subject(); var onStateChanged = async (evt) => { var chr = evt.target; var data = chr.value; if (validateEventData(data)) { eventSubject.next(buildTimerEvent(data)); } else { eventSubject.error("Invalid event data received from Timer"); } }; stateCharacteristic.addEventListener('characteristicvaluechanged', onStateChanged); stateCharacteristic.startNotifications(); // This action retrieves latest recorded times from timer var getRecordedTimesAction = async () => { var data = await timeCharacteristic.readValue(); return data?.byteLength >= 16 ? Promise.resolve({ displayTime: makeTimeFromRaw(data, 0), previousTimes: [makeTimeFromRaw(data, 4), makeTimeFromRaw(data, 8), makeTimeFromRaw(data, 12)] }) : Promise.reject("Invalid time characteristic value received from Timer"); }; // Manual disconnect action var disconnectAction = async () => { device.removeEventListener('gattserverdisconnected', disconnectAction); stateCharacteristic.removeEventListener('characteristicvaluechanged', onStateChanged); await stateCharacteristic.stopNotifications().catch(() => { }); eventSubject.next({ state: exports.GanTimerState.DISCONNECT }); eventSubject.unsubscribe(); if (server.connected) { server.disconnect(); } }; device.addEventListener('gattserverdisconnected', disconnectAction); return { events$: eventSubject, getRecordedTimes: getRecordedTimesAction, disconnect: disconnectAction, }; } /** GAN Gen2 protocol BLE service */ const GAN_GEN2_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dc4179"; /** GAN Gen2 protocol BLE command characteristic */ const GAN_GEN2_COMMAND_CHARACTERISTIC = "28be4a4a-cd67-11e9-a32f-2a2ae2dbcce4"; /** GAN Gen2 protocol BLE state characteristic */ const GAN_GEN2_STATE_CHARACTERISTIC = "28be4cb6-cd67-11e9-a32f-2a2ae2dbcce4"; /** GAN Gen3 protocol BLE service */ const GAN_GEN3_SERVICE = "8653000a-43e6-47b7-9cb0-5fc21d4ae340"; /** GAN Gen3 protocol BLE command characteristic */ const GAN_GEN3_COMMAND_CHARACTERISTIC = "8653000c-43e6-47b7-9cb0-5fc21d4ae340"; /** GAN Gen3 protocol BLE state characteristic */ const GAN_GEN3_STATE_CHARACTERISTIC = "8653000b-43e6-47b7-9cb0-5fc21d4ae340"; /** GAN Gen4 protocol BLE service */ const GAN_GEN4_SERVICE = "00000010-0000-fff7-fff6-fff5fff4fff0"; /** GAN Gen4 protocol BLE command characteristic */ const GAN_GEN4_COMMAND_CHARACTERISTIC = "0000fff5-0000-1000-8000-00805f9b34fb"; /** GAN Gen4 protocol BLE state characteristic */ const GAN_GEN4_STATE_CHARACTERISTIC = "0000fff6-0000-1000-8000-00805f9b34fb"; /** List of Company Identifier Codes, fill with all values [0x0001, 0xFF01] possible for GAN cubes */ const GAN_CIC_LIST = Array(256).fill(undefined).map((_v, i) => (i << 8) | 0x01); /** List of encryption keys */ const GAN_ENCRYPTION_KEYS = [ { key: [0x01, 0x02, 0x42, 0x28, 0x31, 0x91, 0x16, 0x07, 0x20, 0x05, 0x18, 0x54, 0x42, 0x11, 0x12, 0x53], iv: [0x11, 0x03, 0x32, 0x28, 0x21, 0x01, 0x76, 0x27, 0x20, 0x95, 0x78, 0x14, 0x32, 0x12, 0x02, 0x43] }, { key: [0x05, 0x12, 0x02, 0x45, 0x02, 0x01, 0x29, 0x56, 0x12, 0x78, 0x12, 0x76, 0x81, 0x01, 0x08, 0x03], iv: [0x01, 0x44, 0x28, 0x06, 0x86, 0x21, 0x22, 0x28, 0x51, 0x05, 0x08, 0x31, 0x82, 0x02, 0x21, 0x06] } ]; /** * Implementation for encryption scheme used in the GAN Gen2 Smart Cubes */ class GanGen2CubeEncrypter { constructor(key, iv, salt) { if (key.length != 16) throw new Error("Key must be 16 bytes (128-bit) long"); if (iv.length != 16) throw new Error("Iv must be 16 bytes (128-bit) long"); if (salt.length != 6) throw new Error("Salt must be 6 bytes (48-bit) long"); // Apply salt to key and iv this._key = new Uint8Array(key); this._iv = new Uint8Array(iv); for (let i = 0; i < 6; i++) { this._key[i] = (key[i] + salt[i]) % 0xFF; this._iv[i] = (iv[i] + salt[i]) % 0xFF; } } /** Encrypt 16-byte buffer chunk starting at offset using AES-128-CBC */ encryptChunk(buffer, offset) { var cipher = new aesJs.ModeOfOperation.cbc(this._key, this._iv); var chunk = cipher.encrypt(buffer.subarray(offset, offset + 16)); buffer.set(chunk, offset); } /** Decrypt 16-byte buffer chunk starting at offset using AES-128-CBC */ decryptChunk(buffer, offset) { var cipher = new aesJs.ModeOfOperation.cbc(this._key, this._iv); var chunk = cipher.decrypt(buffer.subarray(offset, offset + 16)); buffer.set(chunk, offset); } encrypt(data) { if (data.length < 16) throw Error('Data must be at least 16 bytes long'); var res = new Uint8Array(data); // encrypt 16-byte chunk aligned to message start this.encryptChunk(res, 0); // encrypt 16-byte chunk aligned to message end if (res.length > 16) { this.encryptChunk(res, res.length - 16); } return res; } decrypt(data) { if (data.length < 16) throw Error('Data must be at least 16 bytes long'); var res = new Uint8Array(data); // decrypt 16-byte chunk aligned to message end if (res.length > 16) { this.decryptChunk(res, res.length - 16); } // decrypt 16-byte chunk aligned to message start this.decryptChunk(res, 0); return res; } } /** * Implementation for encryption scheme used in the GAN Gen3 cubes */ class GanGen3CubeEncrypter extends GanGen2CubeEncrypter { } /** * Implementation for encryption scheme used in the GAN Gen3 cubes */ class GanGen4CubeEncrypter extends GanGen2CubeEncrypter { } /** * Return current host clock timestamp with millisecond precision * Use monotonic clock when available * @returns Current host clock timestamp in milliseconds */ const now = typeof window != 'undefined' && typeof window.performance?.now == 'function' ? () => Math.floor(window.performance.now()) : typeof process != 'undefined' && typeof process.hrtime?.bigint == 'function' ? () => Number(process.hrtime.bigint() / 1000000n) : () => Date.now(); function linregress(X, Y) { var sumX = 0; var sumY = 0; var sumXY = 0; var sumXX = 0; var n = 0; for (var i = 0; i < X.length; i++) { var x = X[i]; var y = Y[i]; if (x == null || y == null) { continue; } n++; sumX += x; sumY += y; sumXY += x * y; sumXX += x * x; } var varX = n * sumXX - sumX * sumX; var covXY = n * sumXY - sumX * sumY; var slope = varX < 1e-3 ? 1 : covXY / varX; var intercept = n < 1 ? 0 : sumY / n - slope * sumX / n; return [slope, intercept]; } /** * Use linear regression to fit timestamps reported by cube hardware with host device timestamps * @param cubeMoves List representing window of cube moves to operate on * @returns New copy of move list with fitted cubeTimestamp values */ function cubeTimestampLinearFit(cubeMoves) { var res = []; // Calculate and fix timestamp values for missed and recovered cube moves. if (cubeMoves.length >= 2) { // 1st pass - tail-to-head, align missed move cube timestamps to next move -50ms for (let i = cubeMoves.length - 1; i > 0; i--) { if (cubeMoves[i].cubeTimestamp != null && cubeMoves[i - 1].cubeTimestamp == null) cubeMoves[i - 1].cubeTimestamp = cubeMoves[i].cubeTimestamp - 50; } // 2nd pass - head-to-tail, align missed move cube timestamp to prev move +50ms for (let i = 0; i < cubeMoves.length - 1; i++) { if (cubeMoves[i].cubeTimestamp != null && cubeMoves[i + 1].cubeTimestamp == null) cubeMoves[i + 1].cubeTimestamp = cubeMoves[i].cubeTimestamp + 50; } } // Apply linear regression to the cube timestamps if (cubeMoves.length > 0) { var [slope, intercept] = linregress(cubeMoves.map(m => m.cubeTimestamp), cubeMoves.map(m => m.localTimestamp)); var first = Math.round(slope * cubeMoves[0].cubeTimestamp + intercept); cubeMoves.forEach(m => { res.push({ face: m.face, direction: m.direction, move: m.move, localTimestamp: m.localTimestamp, cubeTimestamp: Math.round(slope * m.cubeTimestamp + intercept) - first }); }); } return res; } /** * Calculate time skew degree in percent between cube hardware and host device * @param cubeMoves List representing window of cube moves to operate on * @returns Time skew value in percent */ function cubeTimestampCalcSkew(cubeMoves) { if (!cubeMoves.length) return 0; var [slope] = linregress(cubeMoves.map(m => m.localTimestamp), cubeMoves.map(m => m.cubeTimestamp)); return Math.round((slope - 1) * 100000) / 1000; } const CORNER_FACELET_MAP = [ [8, 9, 20], // URF [6, 18, 38], // UFL [0, 36, 47], // ULB [2, 45, 11], // UBR [29, 26, 15], // DFR [27, 44, 24], // DLF [33, 53, 42], // DBL [35, 17, 51] // DRB ]; const EDGE_FACELET_MAP = [ [5, 10], // UR [7, 19], // UF [3, 37], // UL [1, 46], // UB [32, 16], // DR [28, 25], // DF [30, 43], // DL [34, 52], // DB [23, 12], // FR [21, 41], // FL [50, 39], // BL [48, 14] // BR ]; /** * * Convert Corner/Edge Permutation/Orientation cube state to the Kociemba facelets representation string * * Example - solved state: * cp = [0, 1, 2, 3, 4, 5, 6, 7] * co = [0, 0, 0, 0, 0, 0, 0, 0] * ep = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] * eo = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] * facelets = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" * Example - state after F R moves made: * cp = [0, 5, 2, 1, 7, 4, 6, 3] * co = [1, 2, 0, 2, 1, 1, 0, 2] * ep = [1, 9, 2, 3, 11, 8, 6, 7, 4, 5, 10, 0] * eo = [1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0] * facelets = "UUFUUFLLFUUURRRRRRFFRFFDFFDRRBDDBDDBLLDLLDLLDLBBUBBUBB" * * @param cp Corner Permutation * @param co Corner Orientation * @param ep Egde Permutation * @param eo Edge Orientation * @returns Cube state in the Kociemba facelets representation string * */ function toKociembaFacelets(cp, co, ep, eo) { var faces = "URFDLB"; var facelets = []; for (let i = 0; i < 54; i++) { facelets[i] = faces[~~(i / 9)]; } for (let i = 0; i < 8; i++) { for (let p = 0; p < 3; p++) { facelets[CORNER_FACELET_MAP[i][(p + co[i]) % 3]] = faces[~~(CORNER_FACELET_MAP[cp[i]][p] / 9)]; } } for (let i = 0; i < 12; i++) { for (let p = 0; p < 2; p++) { facelets[EDGE_FACELET_MAP[i][(p + eo[i]) % 2]] = faces[~~(EDGE_FACELET_MAP[ep[i]][p] / 9)]; } } return facelets.join(''); } /** Calculate sum of all numbers in array */ const sum = arr => arr.reduce((a, v) => a + v, 0); /** * Implementation of classic command/response connection with GAN Smart Cube device */ class GanCubeClassicConnection { constructor(device, commandCharacteristic, stateCharacteristic, encrypter, driver) { this.onStateUpdate = async (evt) => { var characteristic = evt.target; var eventMessage = characteristic.value; if (eventMessage && eventMessage.byteLength >= 16) { var decryptedMessage = this.encrypter.decrypt(new Uint8Array(eventMessage.buffer)); var cubeEvents = await this.driver.handleStateEvent(this, decryptedMessage); cubeEvents.forEach(e => this.events$.next(e)); } }; this.onDisconnect = async () => { this.device.removeEventListener('gattserverdisconnected', this.onDisconnect); this.stateCharacteristic.removeEventListener('characteristicvaluechanged', this.onStateUpdate); this.events$.next({ timestamp: now(), type: "DISCONNECT" }); this.events$.unsubscribe(); return this.stateCharacteristic.stopNotifications().catch(() => { }); }; this.device = device; this.commandCharacteristic = commandCharacteristic; this.stateCharacteristic = stateCharacteristic; this.encrypter = encrypter; this.driver = driver; this.events$ = new rxjs.Subject(); } static async create(device, commandCharacteristic, stateCharacteristic, encrypter, driver) { var conn = new GanCubeClassicConnection(device, commandCharacteristic, stateCharacteristic, encrypter, driver); conn.device.addEventListener('gattserverdisconnected', conn.onDisconnect); conn.stateCharacteristic.addEventListener('characteristicvaluechanged', conn.onStateUpdate); await conn.stateCharacteristic.startNotifications(); return conn; } get deviceName() { return this.device.name || "GAN-XXXX"; } get deviceMAC() { return this.device.mac || "00:00:00:00:00:00"; } async sendCommandMessage(message) { var encryptedMessage = this.encrypter.encrypt(message); return this.commandCharacteristic.writeValue(encryptedMessage); } async sendCubeCommand(command) { var commandMessage = this.driver.createCommandMessage(command); if (commandMessage) { return this.sendCommandMessage(commandMessage); } } async disconnect() { await this.onDisconnect(); if (this.device.gatt?.connected) { this.device.gatt?.disconnect(); } } } /** * View for binary protocol messages allowing to retrieve from message arbitrary length bit words */ class GanProtocolMessageView { constructor(message) { this.bits = Array.from(message).map(byte => (byte + 0x100).toString(2).slice(1)).join(''); } getBitWord(startBit, bitLength, littleEndian = false) { if (bitLength <= 8) { return parseInt(this.bits.slice(startBit, startBit + bitLength), 2); } else if (bitLength == 16 || bitLength == 32) { let buf = new Uint8Array(bitLength / 8); for (let i = 0; i < buf.length; i++) { buf[i] = parseInt(this.bits.slice(8 * i + startBit, 8 * i + startBit + 8), 2); } let dv = new DataView(buf.buffer); return bitLength == 16 ? dv.getUint16(0, littleEndian) : dv.getUint32(0, littleEndian); } else { throw new Error('Unsupproted bit word length'); } } } /** * Driver implementation for GAN Gen2 protocol, supported cubes: * - GAN Mini ui FreePlay * - GAN12 ui FreePlay * - GAN12 ui * - GAN356 i Carry S * - GAN356 i Carry * - GAN356 i 3 * - Monster Go 3Ai */ class GanGen2ProtocolDriver { constructor() { this.lastSerial = -1; this.lastMoveTimestamp = 0; this.cubeTimestamp = 0; } createCommandMessage(command) { var msg = new Uint8Array(20).fill(0); switch (command.type) { case 'REQUEST_FACELETS': msg[0] = 0x04; break; case 'REQUEST_HARDWARE': msg[0] = 0x05; break; case 'REQUEST_BATTERY': msg[0] = 0x09; break; case 'REQUEST_RESET': msg.set([0x0A, 0x05, 0x39, 0x77, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); break; default: msg = undefined; } return msg; } async handleStateEvent(conn, eventMessage) { var timestamp = now(); var cubeEvents = []; var msg = new GanProtocolMessageView(eventMessage); var eventType = msg.getBitWord(0, 4); if (eventType == 0x01) { // GYRO // Orientation Quaternion let qw = msg.getBitWord(4, 16); let qx = msg.getBitWord(20, 16); let qy = msg.getBitWord(36, 16); let qz = msg.getBitWord(52, 16); // Angular Velocity let vx = msg.getBitWord(68, 4); let vy = msg.getBitWord(72, 4); let vz = msg.getBitWord(76, 4); cubeEvents.push({ type: "GYRO", timestamp: timestamp, quaternion: { x: (1 - (qx >> 15) * 2) * (qx & 0x7FFF) / 0x7FFF, y: (1 - (qy >> 15) * 2) * (qy & 0x7FFF) / 0x7FFF, z: (1 - (qz >> 15) * 2) * (qz & 0x7FFF) / 0x7FFF, w: (1 - (qw >> 15) * 2) * (qw & 0x7FFF) / 0x7FFF }, velocity: { x: (1 - (vx >> 3) * 2) * (vx & 0x7), y: (1 - (vy >> 3) * 2) * (vy & 0x7), z: (1 - (vz >> 3) * 2) * (vz & 0x7) } }); } else if (eventType == 0x02) { // MOVE if (this.lastSerial != -1) { // Accept move events only after first facelets state event received let serial = msg.getBitWord(4, 8); let diff = Math.min((serial - this.lastSerial) & 0xFF, 7); this.lastSerial = serial; if (diff > 0) { for (let i = diff - 1; i >= 0; i--) { let face = msg.getBitWord(12 + 5 * i, 4); let direction = msg.getBitWord(16 + 5 * i, 1); let move = "URFDLB".charAt(face) + " '".charAt(direction); let elapsed = msg.getBitWord(47 + 16 * i, 16); if (elapsed == 0) { // In case of 16-bit cube timestamp register overflow elapsed = timestamp - this.lastMoveTimestamp; } this.cubeTimestamp += elapsed; cubeEvents.push({ type: "MOVE", serial: (serial - i) & 0xFF, timestamp: timestamp, localTimestamp: i == 0 ? timestamp : null, // Missed and recovered events has no meaningfull local timestamps cubeTimestamp: this.cubeTimestamp, face: face, direction: direction, move: move.trim() }); } this.lastMoveTimestamp = timestamp; } } } else if (eventType == 0x04) { // FACELETS let serial = msg.getBitWord(4, 8); if (this.lastSerial == -1) this.lastSerial = serial; // Corner/Edge Permutation/Orientation let cp = []; let co = []; let ep = []; let eo = []; // Corners for (let i = 0; i < 7; i++) { cp.push(msg.getBitWord(12 + i * 3, 3)); co.push(msg.getBitWord(33 + i * 2, 2)); } cp.push(28 - sum(cp)); co.push((3 - (sum(co) % 3)) % 3); // Edges for (let i = 0; i < 11; i++) { ep.push(msg.getBitWord(47 + i * 4, 4)); eo.push(msg.getBitWord(91 + i, 1)); } ep.push(66 - sum(ep)); eo.push((2 - (sum(eo) % 2)) % 2); cubeEvents.push({ type: "FACELETS", serial: serial, timestamp: timestamp, facelets: toKociembaFacelets(cp, co, ep, eo), state: { CP: cp, CO: co, EP: ep, EO: eo }, }); } else if (eventType == 0x05) { // HARDWARE let hwMajor = msg.getBitWord(8, 8); let hwMinor = msg.getBitWord(16, 8); let swMajor = msg.getBitWord(24, 8); let swMinor = msg.getBitWord(32, 8); let gyroSupported = msg.getBitWord(104, 1); let hardwareName = ''; for (var i = 0; i < 8; i++) { hardwareName += String.fromCharCode(msg.getBitWord(i * 8 + 40, 8)); } cubeEvents.push({ type: "HARDWARE", timestamp: timestamp, hardwareName: hardwareName, hardwareVersion: `${hwMajor}.${hwMinor}`, softwareVersion: `${swMajor}.${swMinor}`, gyroSupported: !!gyroSupported }); } else if (eventType == 0x09) { // BATTERY let batteryLevel = msg.getBitWord(8, 8); cubeEvents.push({ type: "BATTERY", timestamp: timestamp, batteryLevel: Math.min(batteryLevel, 100) }); } else if (eventType == 0x0D) { // DISCONNECT conn.disconnect(); } return cubeEvents; } } /** * Driver implementation for GAN Gen3 protocol, supported cubes: * - GAN356 i Carry 2 */ class GanGen3ProtocolDriver { constructor() { this.serial = -1; this.lastSerial = -1; this.lastLocalTimestamp = null; this.moveBuffer = []; } createCommandMessage(command) { var msg = new Uint8Array(16).fill(0); switch (command.type) { case 'REQUEST_FACELETS': msg.set([0x68, 0x01]); break; case 'REQUEST_HARDWARE': msg.set([0x68, 0x04]); break; case 'REQUEST_BATTERY': msg.set([0x68, 0x07]); break; case 'REQUEST_RESET': msg.set([0x68, 0x05, 0x05, 0x39, 0x77, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0x00, 0x00, 0x00]); break; default: msg = undefined; } return msg; } /** Private cube command for requesting move history */ async requestMoveHistory(conn, serial, count) { var msg = new Uint8Array(16).fill(0); // Move history response data is byte-aligned, and moves always starting with near-ceil odd serial number, regardless of requested. // Adjust serial and count to get odd serial aligned history window with even number of moves inside. if (serial % 2 == 0) serial = (serial - 1) & 0xFF; if (count % 2 == 1) count++; // Never overflow requested history window beyond the serial number cycle edge 255 -> 0. // Because due to iCarry2 firmware bug the moves beyond the edge will be spoofed with 'D' (just zero bytes). count = Math.min(count, serial + 1); msg.set([0x68, 0x03, serial, 0, count, 0]); return conn.sendCommandMessage(msg).catch(() => { // We can safely suppress and ignore possible GATT write errors, requestMoveHistory command is automatically retried on next move event }); } /** * Evict move events from FIFO buffer until missing move event detected * In case of missing move, and if connection is provided, submit request for move history to fill gap in buffer */ async evictMoveBuffer(conn) { var evictedEvents = []; while (this.moveBuffer.length > 0) { let bufferHead = this.moveBuffer[0]; let diff = this.lastSerial == -1 ? 1 : (bufferHead.serial - this.lastSerial) & 0xFF; if (diff > 1) { if (conn) { await this.requestMoveHistory(conn, bufferHead.serial, diff); } break; } else { evictedEvents.push(this.moveBuffer.shift()); this.lastSerial = bufferHead.serial; } } // Probably something went wrong and buffer is no longer evicted, so forcibly disconnect the cube if (conn && this.moveBuffer.length > 16) { conn.disconnect(); } return evictedEvents; } /** * Check if circular serial number (modulo 256) fits into (start,end) serial number range. * By default range is open, set closedStart / closedEnd to make it closed. */ isSerialInRange(start, end, serial, closedStart = false, closedEnd = false) { return ((end - start) & 0xFF) >= ((serial - start) & 0xFF) && (closedStart || ((start - serial) & 0xFF) > 0) && (closedEnd || ((end - serial) & 0xFF) > 0); } /** Used to inject missed moves to FIFO buffer */ injectMissedMoveToBuffer(move) { if (move.type == "MOVE") { if (this.moveBuffer.length > 0) { var bufferHead = this.moveBuffer[0]; // Skip if move event with the same serial already in the buffer if (this.moveBuffer.some(e => e.type == "MOVE" && e.serial == move.serial)) return; // Skip if move serial does not fit in range between last evicted event and event on buffer head, i.e. event must be one of missed if (!this.isSerialInRange(this.lastSerial, bufferHead.serial, move.serial)) return; // Move history events should be injected in reverse order, so just put suitable event on buffer head if (move.serial == ((bufferHead.serial - 1) & 0xFF)) { this.moveBuffer.unshift(move); } } else { // This case happens when lost move is recovered using periodic // facelets state event, and being inserted into the empty buffer. if (this.isSerialInRange(this.lastSerial, this.serial, move.serial, false, true)) { this.moveBuffer.unshift(move); } } } } /** Used in response to periodic facelets event to check if any moves missed */ async checkIfMoveMissed(conn) { let diff = (this.serial - this.lastSerial) & 0xFF; if (diff > 0) { if (this.serial != 0) { // Constraint to avoid iCarry2 firmware bug with facelets state event at 255 move counter let bufferHead = this.moveBuffer[0]; let startSerial = bufferHead ? bufferHead.serial : (this.serial + 1) & 0xFF; await this.requestMoveHistory(conn, startSerial, diff + 1); } } } async handleStateEvent(conn, eventMessage) { var timestamp = now(); var cubeEvents = []; var msg = new GanProtocolMessageView(eventMessage); var magic = msg.getBitWord(0, 8); var eventType = msg.getBitWord(8, 8); var dataLength = msg.getBitWord(16, 8); if (magic == 0x55 && dataLength > 0) { if (eventType == 0x01) { // MOVE if (this.lastSerial != -1) { // Accept move events only after first facelets state event received this.lastLocalTimestamp = timestamp; let cubeTimestamp = msg.getBitWord(24, 32, true); let serial = this.serial = msg.getBitWord(56, 16, true); let direction = msg.getBitWord(72, 2); let face = [2, 32, 8, 1, 16, 4].indexOf(msg.getBitWord(74, 6)); let move = "URFDLB".charAt(face) + " '".charAt(direction); // put move event into FIFO buffer if (face >= 0) { this.moveBuffer.push({ type: "MOVE", serial: serial, timestamp: timestamp, localTimestamp: timestamp, cubeTimestamp: cubeTimestamp, face: face, direction: direction, move: move.trim() }); } // evict move events from FIFO buffer cubeEvents = await this.evictMoveBuffer(conn); } } else if (eventType == 0x06) { // MOVE_HISTORY let startSerial = msg.getBitWord(24, 8); let count = (dataLength - 1) * 2; // inject missed moves into FIFO buffer for (let i = 0; i < count; i++) { let face = [1, 5, 3, 0, 4, 2].indexOf(msg.getBitWord(32 + 4 * i, 3)); let direction = msg.getBitWord(35 + 4 * i, 1); if (face >= 0) { let move = "URFDLB".charAt(face) + " '".charAt(direction); this.injectMissedMoveToBuffer({ type: "MOVE", serial: (startSerial - i) & 0xFF, timestamp: timestamp, localTimestamp: null, // Missed and recovered events has no meaningfull local timestamps cubeTimestamp: null, // Cube hardware timestamp for missed move you should interpolate using cubeTimestampLinearFit face: face, direction: direction, move: move.trim() }); } } // evict move events from FIFO buffer cubeEvents = await this.evictMoveBuffer(); } else if (eventType == 0x02) { // FACELETS let serial = this.serial = msg.getBitWord(24, 16, true); // Also check and recovery missed moves using periodic facelets event sent by cube if (this.lastSerial != -1) { // Debounce the facelet event if there are active cube moves if (this.lastLocalTimestamp != null && (timestamp - this.lastLocalTimestamp) > 500) { await this.checkIfMoveMissed(conn); } } if (this.lastSerial == -1) this.lastSerial = serial; // Corner/Edge Permutation/Orientation let cp = []; let co = []; let ep = []; let eo = []; // Corners for (let i = 0; i < 7; i++) { cp.push(msg.getBitWord(40 + i * 3, 3)); co.push(msg.getBitWord(61 + i * 2, 2)); } cp.push(28 - sum(cp)); co.push((3 - (sum(co) % 3)) % 3); // Edges for (let i = 0; i < 11; i++) { ep.push(msg.getBitWord(77 + i * 4, 4)); eo.push(msg.getBitWord(121 + i, 1)); } ep.push(66 - sum(ep)); eo.push((2 - (sum(eo) % 2)) % 2); cubeEvents.push({ type: "FACELETS", serial: serial, timestamp: timestamp, facelets: toKociembaFacelets(cp, co, ep, eo), state: { CP: cp, CO: co, EP: ep, EO: eo }, }); } else if (eventType == 0x07) { // HARDWARE let swMajor = msg.getBitWord(72, 4); let swMinor = msg.getBitWord(76, 4); let hwMajor = msg.getBitWord(80, 4); let hwMinor = msg.getBitWord(84, 4); let hardwareName = ''; for (var i = 0; i < 5; i++) { hardwareName += String.fromCharCode(msg.getBitWord(i * 8 + 32, 8)); } cubeEvents.push({ type: "HARDWARE", timestamp: timestamp, hardwareName: hardwareName, hardwareVersion: `${hwMajor}.${hwMinor}`, softwareVersion: `${swMajor}.${swMinor}`, gyroSupported: false }); } else if (eventType == 0x10) { // BATTERY let batteryLevel = msg.getBitWord(24, 8); cubeEvents.push({ type: "BATTERY", timestamp: timestamp, batteryLevel: Math.min(batteryLevel, 100) }); } else if (eventType == 0x11) { // DISCONNECT conn.disconnect(); } } return cubeEvents; } } /** * Driver implementation for GAN Gen4 protocol, supported cubes: * - GAN12 ui Maglev * - GAN14 ui FreePlay */ class GanGen4ProtocolDriver { constructor() { this.serial = -1; this.lastSerial = -1; this.lastLocalTimestamp = null; this.moveBuffer = []; // Used to store partial result acquired from hardware info events this.hwInfo = {}; } createCommandMessage(command) { var msg = new Uint8Array(20).fill(0); switch (command.type) { case 'REQUEST_FACELETS': msg.set([0xDD, 0x04, 0x00, 0xED, 0x00, 0x00]); break; case 'REQUEST_HARDWARE': this.hwInfo = {}; msg.set([0xDF, 0x03, 0x00, 0x00, 0x00]); break; case 'REQUEST_BATTERY': msg.set([0xDD, 0x04, 0x00, 0xEF, 0x00, 0x00]); break; case 'REQUEST_RESET': msg.set([0xD2, 0x0D, 0x05, 0x39, 0x77, 0x00, 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0x00, 0x00, 0x00]); break; default: msg = undefined; } return msg; } /** Private cube command for requesting move history */ async requestMoveHistory(conn, serial, count) { var msg = new Uint8Array(20).fill(0); // Move history response data is byte-aligned, and moves always starting with near-ceil odd serial number, regardless of requested. // Adjust serial and count to get odd serial aligned history window with even number of moves inside. if (serial % 2 == 0) serial = (serial - 1) & 0xFF; if (count % 2 == 1) count++; // Never overflow requested history window beyond the serial number cycle edge 255 -> 0. // Because due to firmware bug the moves beyond the edge will be spoofed with 'D' (just zero bytes). count = Math.min(count, serial + 1); msg.set([0xD1, 0x04, serial, 0, count, 0]); return conn.sendCommandMessage(msg).catch(() => { // We can safely suppress and ignore possible GATT write errors, requestMoveHistory command is automatically retried on next move event }); } /** * Evict move events from FIFO buffer until missing move event detected * In case of missing move, and if connection is provided, submit request for move history to fill gap in buffer */ async evictMoveBuffer(conn) { var evictedEvents = []; while (this.moveBuffer.length > 0) { let bufferHead = this.moveBuffer[0]; let diff = this.lastSerial == -1 ? 1 : (bufferHead.serial - this.lastSerial) & 0xFF; if (diff > 1) { if (conn) { await this.requestMoveHistory(conn, bufferHead.serial, diff); } break; } else { evictedEvents.push(this.moveBuffer.shift()); this.lastSerial = bufferHead.serial; } } // Probably something went wrong and buffer is no longer evicted, so forcibly disconnect the cube if (conn && this.moveBuffer.length > 16) { conn.disconnect(); } return evictedEvents; } /** * Check if circular serial number (modulo 256) fits into (start,end) serial number range. * By default range is open, set closedStart / closedEnd to make it closed. */ isSerialInRange(start, end, serial, closedStart = false, closedEnd = false) { return ((end - start) & 0xFF) >= ((serial - start) & 0xFF) && (closedStart || ((start - serial) & 0xFF) > 0) && (closedEnd || ((end - serial) & 0xFF) > 0); } /** Used to inject missed moves to FIFO buffer */ injectMissedMoveToBuffer(move) { if (move.type == "MOVE") { if (this.moveBuffer.length > 0) { var bufferHead = this.moveBuffer[0]; // Skip if move event with the same serial already in the buffer if (this.moveBuffer.some(e => e.type == "MOVE" && e.serial == move.serial)) return; // Skip if move serial does not fit in range between last evicted event and event on buffer head, i.e. event must be one of missed if (!this.isSerialInRange(this.lastSerial, bufferHead.serial, move.serial)) return; // Move history events should be injected in reverse order, so just put suitable event on buffer head if (move.serial == ((bufferHead.serial - 1) & 0xFF)) { this.moveBuffer.unshift(move); } } else { // This case happens when lost move is recovered using periodic // facelets state event, and being inserted into the empty buffer. if (this.isSerialInRange(this.lastSerial, this.serial, move.serial, false, true)) { this.moveBuffer.unshift(move); } } } } /** Used in response to periodic facelets event to check if any moves missed */ async checkIfMoveMissed(conn) { let diff = (this.serial - this.lastSerial) & 0xFF; if (diff > 0) { if (this.serial != 0) { // Constraint to avoid firmware bug with facelets state event at 255 move counter let bufferHead = this.moveBuffer[0]; let startSerial = bufferHead ? bufferHead.serial : (this.serial + 1) & 0xFF; await this.requestMoveHistory(conn, startSerial, diff + 1); } } } async handleStateEvent(conn, eventMessage) { var timestamp = now(); var cubeEvents = []; var msg = new GanProtocolMessageView(eventMessage); var eventType = msg.getBitWord(0, 8); var dataLength = msg.getBitWord(8, 8); if (eventType == 0x01) { // MOVE if (this.lastSerial != -1) { // Accept move events only after first facelets state event received this.lastLocalTimestamp = timestamp; let cubeTimestamp = msg.getBitWord(16, 32, true); let serial = this.serial = msg.getBitWord(48, 16, true); let direction = msg.getBitWord(64, 2); let face = [2, 32, 8, 1, 16, 4].indexOf(msg.getBitWord(66, 6)); let move = "URFDLB".charAt(face) + " '".charAt(direction); // put move event into FIFO buffer if (face >= 0) { this.moveBuffer.push({ type: "MOVE", serial: serial, timestamp: timestamp, localTimestamp: timestamp, cubeTimestamp: cubeTimestamp, face: face, direction: direction, move: move.trim() }); } // evict move events from FIFO buffer cubeEvents = await this.evictMoveBuffer(conn); } } else if (eventType == 0xD1) { // MOVE_HISTORY let startSerial = msg.getBitWord(16, 8); let count = (dataLength - 1) * 2; // inject missed moves into FIFO buffer for (let i = 0; i < count; i++) { let face = [1, 5, 3, 0, 4, 2].indexOf(msg.getBitWord(24 + 4 * i, 3)); let direction = msg.getBitWord(27 + 4 * i, 1); if (face >= 0) { let move = "URFDLB".charAt(face) + " '".charAt(direction); this.injectMissedMoveToBuffer({ type: "MOVE", serial: (startSerial - i) & 0xFF, timestamp: timestamp, localTimestamp: null, // Missed and recovered events has no meaningfull local timestamps cubeTimestamp: null, // Cube hardware timestamp for missed move you should interpolate using cubeTimestampLinearFit face: face, direction: direction, move: move.trim() }); } } // evict move events from FIFO buffer cubeEvents = await this.evictMoveBuffer(); } else if (eventType == 0xED) { // FACELETS let serial = this.serial = msg.getBitWord(16, 16, true); // Also check and recovery missed moves using periodic facelets event sent by cube if (this.lastSerial != -1) { // Debounce the facelet event if there are active cube moves if (this.lastLocalTimestamp != null && (timestamp - this.lastLocalTimestamp) > 500) { await this.checkIfMoveMissed(conn); } } if (this.lastSerial == -1) this.lastSerial = serial; // Corner/Edge Permutation/Orientation let cp = []; let co = []; let ep = []; let eo = []; // Corners for (let i = 0; i < 7; i++) { cp.push(msg.getBitWord(32 + i * 3, 3)); co.push(msg.getBitWord(53 + i * 2, 2)); } cp.push(28 - sum(cp)); co.push((3 - (sum(co) % 3)) % 3); // Edges for (let i = 0; i < 11; i++) { ep.push(msg.getBitWord(69 + i * 4, 4)); eo.push(msg.getBitWord(113 + i, 1)); } ep.push(66 - sum(ep)); eo.push((2 - (sum(eo) % 2)) % 2); cubeEvents.push({ type: "FACELETS", serial: serial, timestamp: timestamp, facelets: toKociembaFacelets(cp, co, ep, eo), state: { CP: cp, CO: co, EP: ep, EO: eo }, }); } else if (eventType >= 0xFA && eventType <= 0xFE) { // HARDWARE switch (eventType) { case 0xFA: // Product Date let year = msg.getBitWord(24, 16, true); let month = msg.getBitWord(40, 8); let day = msg.getBitWord(48, 8); this.hwInfo[eventType] = `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; break; case 0xFC: // Hardware Name this.hwInfo[eventType] = ''; for (var i = 0; i < dataLength - 1; i++) { this.hwInfo[eventType] += String.fromCharCode(msg.getBitWord(i * 8 + 24, 8)); } break; case 0xFD: // Software Version let swMajor = msg.getBitWord(24, 4); let swMinor = msg.getBitWord(28, 4); this.hwInfo[eventType] = `${swMajor}.${swMinor}`; break; case 0xFE: // Hardware Version let hwMajor = msg.getBitWord(24, 4); let hwMinor = msg.getBitWord(28, 4); this.hwInfo[eventType] = `${hwMajor}.${hwMinor}`; break; } if (Object.keys(this.hwInfo).length == 4) { // All fields are populated cubeEvents.push({ type: "HARDWARE", timestamp: timestamp, hardwareName: this.hwInfo[0xFC], hardwareVersion: this.hwInfo[0xFE], softwareVersion: this.hwInfo[0xFD], productDate: this.hwInfo[0xFA], gyroSupported: ['GAN12uiM'].indexOf(this.hwInfo[0xFC]) != -1 }); } } else if (eventType == 0xEC) { // GYRO // Orientation Quaternion let qw = msg.getBitWord(16, 16); let qx = msg.getBitWord(32, 16); let qy = msg.getBitWord(48, 16); let qz = msg.getBitWord(64, 16); // Angular Velocity let vx = msg.getBitWord(80, 4); let vy = msg.getBitWord(84, 4); let vz = msg.getBitWord(88, 4); cubeEvents.push({ type: "GYRO", timestamp: timestamp, quaternion: { x: (1 - (qx >> 15) * 2) * (qx & 0x7FFF) / 0x7FFF, y: (1 - (qy >> 15) * 2) * (qy & 0x7FFF) / 0x7FFF, z: (1 - (qz >> 15) * 2) * (qz & 0x7FFF) / 0x7FFF, w: (1 - (qw >> 15) * 2) * (qw & 0x7FFF) / 0x7FFF }, velocity: { x: (1 - (vx >> 3) * 2) * (vx & 0x7), y: (1 - (vy >> 3) * 2) * (vy & 0x7), z: (1 - (vz >> 3) * 2) * (vz & 0x7) } }); } else if (eventType == 0xEF) { // BATTERY let batteryLevel = msg.getBitWord(8 + dataLength * 8, 8); cubeEvents.push({ type: "BATTERY", timestamp: timestamp, batteryLevel: Math.min(batteryLevel, 100) }); } else if (eventType == 0xEA) { // DISCONNECT conn.disconnect(); } return cubeEvents; } } /** Iterate over all known GAN cube CICs to find Manufacturer Specific Data */ function getManufacturerDataBytes(manufacturerData) { // Workaround for Bluefy browser which may return raw DataView directly instead of Map if (manufacturerData instanceof DataView) { return new DataView(manufacturerData.buffer.slice(2, 11)); } for (var id of GAN_CIC_LIST) { if (manufacturerData.has(id)) { return new DataView(manufacturerData.get(id).buffer.slice(0, 9)); } } return; } /** Extract MAC from last 6 bytes of Manufacturer Specific Data */ function extractMAC(manufacturerData) { var mac = []; var dataView = getManufacturerDataBytes(manufacturerData); if (dataView && dataView.byteLength >= 6) { for (let i = 1; i <= 6; i++) { mac.push(dataView.getUint8(dataView.byteLength - i).toString(16).toUpperCase().padStart(2, "0")); } } return mac.join(":"); } /** If browser supports Web Bluetooth watchAdvertisements() API, try to retrieve MAC address automatically */ async function autoRetrieveMacAddress(device) { return new Promise((resolve) => { if (typeof device.watchAdvertisements != 'function') { resolve(null); } var abortController = new AbortController(); var onAdvEvent = (evt) => { device.removeEventListener("advertisementreceived", onAdvEvent); abortController.abort(); var mac = extractMAC(evt.manufacturerData); resolve(mac || null); }; var onAbort = () => { device.removeEventListener("advertisementreceived", onAdvEvent); abortController.abort(); resolve(null); }; device.addEventListener("advertisementreceived", onAdvEvent); device.watchAdvertisements({ signal: abortController.signal }).catch(onAbort); setTimeout(onAbort, 10000); }); } /** * Initiate new connection with the GAN Smart Cube device * @param customMacAddressProvider Optional custom provider for cube MAC address * @returns Object representing connection API and state */ async function connectGanCube(customMacAddressProvider) { // Request user for the bluetooth device (popup selection dialog) var device = await navigator.bluetooth.requestDevice({ filters: [ { namePrefix: "GAN" }, { namePrefix: "MG" }, { namePrefix: "AiCube" } ], optionalServices: [GAN_GEN2_SERVICE, GAN_GEN3_SERVICE, GAN_GEN4_SERVICE], optionalManufacturerData: GAN_CIC_LIST }); // Retrieve cube MAC address needed for key salting var mac = customMacAddressProvider && await customMacAddressProvider(device, false) || await autoRetrieveMacAddress(device) || customMacAddressProvider && await customMacAddressProvider(device, true); if (!mac) throw new Error('Unable to determine cube MAC address, connection is not possible!'); device.mac = mac; // Create encryption salt from MAC address bytes placed in reverse order var salt = new Uint8Array(device.mac.split(/[:-\s]+/).map((c) => parseInt(c, 16)).reverse()); // Connect to GATT and get device primary services var gatt = await device.gatt.connect(); var services = await gatt.getPrimaryServices(); var conn = null; // Resolve type of connected cube device and setup appropriate encryption / protocol driver for (let service of services) { let serviceUUID = service.uuid.toLowerCase(); if (serviceUUID == GAN_GEN2_SERVICE) { let commandCharacteristic = await service.getCharacteristic(GAN_GEN2_COMMAND_CHARACTERISTIC); let stateCharacteristic = await service.getCharacteristic(GAN_GEN2_STATE_CHARACTERISTIC); let key = device.name?.startsWith('AiCube') ? GAN_ENCRYPTION_KEYS[1] : GAN_ENCRYPTION_KEYS[0]; let encrypter = new GanGen2CubeEncrypter(new Uint8Array(key.key), new Uint8Array(key.iv), salt); let driver = new GanGen2ProtocolDriver(); conn = await GanCubeClassicConnection.create(device, commandCharacteristic, stateCharacteristic, encrypter, driver); break; } else if (serviceUUID == GAN_GEN3_SERVICE) { let commandCharacteristic = await service.getCharacteristic(GAN_GEN3_COMMAND_CHARACTERISTIC); let stateCharacteristic = await service.getCharacteristic(GAN_GEN3_STATE_CHARACTERISTIC); let key = GAN_ENCRYPTION_KEYS[0]; let encrypter = new GanGen3CubeEncrypter(new Uint8Array(key.key), new Uint8Array(key.iv), salt); let driver = new GanGen3ProtocolDriver(); conn = await GanCubeClassicConnection.create(device, commandCharacteristic, stateCharacteristic, encrypter, driver); break; } else if (serviceUUID == GAN_GEN4_SERVICE) { let commandCharacteristic = await service.getCharacteristic(GAN_GEN4_COMMAND_CHARACTERISTIC); let stateCharacteristic = await service.getCharacteristic(GAN_GEN4_STATE_CHARACTERISTIC); let key = GAN_ENCRYPTION_KEYS[0]; let encrypter = new GanGen4CubeEncrypter(new Uint8Array(key.key), new Uint8Array(key.iv), salt); let driver = new GanGen4ProtocolDriver(); conn = await GanCubeClassicConnection.create(device, commandCharacteristic, stateCharacteristic, encrypter, driver); break; } } if (!conn) throw new Error("Can't find target BLE services - wrong or unsupported cube device model"); return conn; } exports.connectGanCube = connectGanCube; exports.connectGanTimer = connectGanTimer; exports.cubeTimestampCalcSkew = cubeTimestampCalcSkew; exports.cubeTimestampLinearFit = cubeTimestampLinearFit; exports.makeTime = makeTime; exports.makeTimeFromTimestamp = makeTimeFromTimestamp; exports.now = now; exports.toKociembaFacelets = toKociembaFacelets;