很早之前,开发过一个魔方游戏,也一直想用智能蓝牙来连接,可惜教程什么的太少了,如何连接,如何解密与收取数据,一直是个难题,unity又没有全平台的蓝牙api,想了很久还是web的ble好用,参考的提取的主要是Cstimer简化实现,之前在blbl有问过原作者是如何解析的,他说那部分是老外写的,具体是如何实现的,他也不知,那时我用phpstudy边注释边console.log只知道有54个面,怎么用的不知,怎么写的不知,反正就是一痛苦面具。

一、蓝牙Web Bluetooth

        用的是奇艺的智能魔方,它很便宜,所以用的是它,主要用的教程是:通过 JavaScript 与蓝牙设备通信,还有就是全面的Web_Bluetooth_API,这个可以让你很好的入门这个简单的,当网站使用 navigator.bluetooth.requestDevice 请求访问附近的设备时,浏览器会向用户显示设备选择器,用户可以在其中选择一台设备或取消请求,这个函数接受一个用于定义过滤条件的必需对象,这些过滤器用于仅返回与某些已通告的蓝牙 GATT 服务和/或设备名称匹配的设备,此外为了安全必需用户手动触发,下面就双击事件案例:

window.addEventListener("dblclick", async () =>{    //   双击事件
    console.log("请求 BLE 设备");
    const device = await navigator.bluetooth.requestDevice({
    acceptAllDevices: true,
    //optionalServices: [SERVICE_UUID] // 这里加上你要访问的所有 service UUID
    });
    console.log('设备:', device.name);
});

二、魔方训练Cstimer

        这是训练魔方的网站,它的源码在github上开源了,很实用可以点一下start,有时真的即使代码放你面前,可是你就是看不懂的崩感,就要是不了解它,看它我也花了好长时间才懂,主要用到的代码是cstimer/src/js/hardware/qiyucube.js,代码如下所示:

execMain(function() {
	var _gatt;
	var _service;
	var _deviceName;
	var _chrct_cube;
	var UUID_SUFFIX = '-0000-1000-8000-00805f9b34fb';
	var SERVICE_UUID = '0000fff0' + UUID_SUFFIX;
	var CHRCT_UUID_CUBE = '0000fff6' + UUID_SUFFIX;

	var QIYI_CIC_LIST = [0x0504];

	var decoder = null;
	var deviceMac = null;
	var KEYS = ['NoDg7ANAjGkEwBYCc0xQnADAVgkzGAzHNAGyRTanQi5QIFyHrjQMQgsC6QA'];

	function initMac(forcePrompt, isWrongKey) {
		var defaultMac = null;
		if (/^(QY-QYSC|XMD-TornadoV4-i)-.-[0-9A-F]{4}$/.exec(_deviceName)) {
			defaultMac = 'CC:A3:00:00:' + _deviceName.slice(-4, -2) + ':' + _deviceName.slice(-2);
		}
		deviceMac = giikerutil.reqMacAddr(forcePrompt, isWrongKey, deviceMac, defaultMac);
	}

	function crc16modbus(data) {
		var crc = 0xFFFF;
		for (var i = 0; i < data.length; i++) {
			crc ^= data[i];
			for (var j = 0; j < 8; j++) {
				crc = (crc & 0x1) > 0 ? (crc >> 1) ^ 0xa001 : crc >> 1;
			}
		}
		return crc;
	}

	// content: [u8, u8, ..]
	function sendMessage(content) {
		if (!_chrct_cube || DEBUGBL) {
			return DEBUGBL ? Promise.resolve() : Promise.reject();
		}
		var msg = [0xfe];
		msg.push(4 + content.length); // length = 1 (op) + cont.length + 2 (crc)
		for (var i = 0; i < content.length; i++) {
			msg.push(content[i]);
		}
		var crc = crc16modbus(msg);
		msg.push(crc & 0xff, crc >> 8);
		var npad = (16 - msg.length % 16) % 16;
		for (var i = 0; i < npad; i++) {
			msg.push(0);
		}
		var encMsg = [];
		decoder = decoder || $.aes128(JSON.parse(LZString.decompressFromEncodedURIComponent(KEYS[0])));
		for (var i = 0; i < msg.length; i += 16) {
			var block = msg.slice(i, i + 16);
			decoder.encrypt(block);
			for (var j = 0; j < 16; j++) {
				encMsg[i + j] = block[j];
			}
		}
		giikerutil.log('[qiyicube] send message to cube', msg, encMsg);
		return _chrct_cube.writeValue(new Uint8Array(encMsg).buffer);
	}

	function sendHello(mac) {
		if (!mac) {
			return Promise.reject('empty mac');
		}
		var content = [0x00, 0x6b, 0x01, 0x00, 0x00, 0x22, 0x06, 0x00, 0x02, 0x08, 0x00];
		for (var i = 5; i >= 0; i--) {
			content.push(parseInt(mac.slice(i * 3, i * 3 + 2), 16));
		}
		return sendMessage(content);
	}

	function getManufacturerDataBytes(mfData) {
		if (mfData instanceof DataView) { // this is workaround for Bluefy browser
			return new DataView(mfData.buffer.slice(2));
		}
		for (var id of QIYI_CIC_LIST) {
			if (mfData.has(id)) {
				giikerutil.log('[qiyicube] found Manufacturer Data under CIC = 0x' + id.toString(16).padStart(4, '0'));
				return mfData.get(id);
			}
		}
		giikerutil.log('[qiyicube] Looks like this cube has new unknown CIC');
	}

	function init(device) {
		clear();
		_deviceName = device.name.trim();
		giikerutil.log('[qiyicube] start init device');
		return GiikerCube.waitForAdvs().then(function(mfData) {
			var dataView = getManufacturerDataBytes(mfData);
			if (dataView && dataView.byteLength >= 6) {
				var mac = [];
				for (var i = 5; i >= 0; i--) {
					mac.push((dataView.getUint8(i) + 0x100).toString(16).slice(1));
				}
				return Promise.resolve(mac.join(':'));
			}
			return Promise.reject(-3);
		}).then(function(mac) {
			giikerutil.log('[qiyicube] init, found cube bluetooth hardware MAC = ' + mac);
			deviceMac = mac;
		}, function(err) {
			giikerutil.log('[qiyicube] init, unable to automatically determine cube MAC, error code = ' + err);
		}).then(function() {
			return device.gatt.connect();
		}).then(function(gatt) {
			_gatt = gatt;
			return gatt.getPrimaryService(SERVICE_UUID);
		}).then(function(service) {
			_service = service;
			giikerutil.log('[qiyicube] got primary service', SERVICE_UUID);
			return _service.getCharacteristics();
		}).then(function(chrcts) {
			giikerutil.log('[qiyicube] find chrcts', chrcts);
			_chrct_cube = GiikerCube.findUUID(chrcts, CHRCT_UUID_CUBE);
		}).then(function() {
			_chrct_cube.addEventListener('characteristicvaluechanged', onCubeEvent);
			return _chrct_cube.startNotifications();
		}).then(function() {
			initMac(true);
			return sendHello(deviceMac);
		});
	}

	function onCubeEvent(event) {
		var value = event.target.value;
		var encMsg = [];
		for (var i = 0; i < value.byteLength; i++) {
			encMsg[i] = value.getUint8(i);
		}
		giikerutil.log('[qiyicube] receive enc data', encMsg);
		decoder = decoder || $.aes128(JSON.parse(LZString.decompressFromEncodedURIComponent(KEYS[0])));
		var msg = [];
		for (var i = 0; i < encMsg.length; i += 16) {
			var block = encMsg.slice(i, i + 16);
			decoder.decrypt(block);
			for (var j = 0; j < 16; j++) {
				msg[i + j] = block[j];
			}
		}
		giikerutil.log('[qiyicube] decrypted msg', msg);
		msg = msg.slice(0, msg[1]);
		if (msg.length < 3 || crc16modbus(msg) != 0) {
			giikerutil.log('[qiyicube] crc checked error');
			return;
		}
		parseCubeData(msg);
	}

	var curCubie = new mathlib.CubieCube();
	var prevCubie = new mathlib.CubieCube();
	var prevMoves = [];
	var lastTs = 0;
	var batteryLevel = 0;

	function parseCubeData(msg) {
		var locTime = $.now();
		if (msg[0] != 0xfe) {
			giikerutil.log('[qiyicube] error cube data', msg);
			return;
		}
		var opcode = msg[2];
		var ts = (msg[3] << 24 | msg[4] << 16 | msg[5] << 8 | msg[6]);
		if (opcode == 0x2) { // cube hello
			batteryLevel = msg[35];
			sendMessage(msg.slice(2, 7));
			var newFacelet = parseFacelet(msg.slice(7, 34));
			GiikerCube.callback(newFacelet, [], [Math.trunc(ts / 1.6), locTime], _deviceName);
			prevCubie.fromFacelet(newFacelet);
			if (newFacelet != kernel.getProp('giiSolved', mathlib.SOLVED_FACELET)) {
				var rst = kernel.getProp('giiRST');
				if (rst == 'a' || rst == 'p' && confirm(CONFIRM_GIIRST)) {
					giikerutil.markSolved();
				}
			}
		} else if (opcode == 0x3) { // state change
			sendMessage(msg.slice(2, 7));
			// check timestamps
			var todoMoves = [[msg[34], ts]];
			while (todoMoves.length < 10) {
				var off = 91 - 5 * todoMoves.length;
				var hisTs = (msg[off] << 24 | msg[off + 1] << 16 | msg[off + 2] << 8 | msg[off + 3]);
				var hisMv = msg[off + 4];
				if (hisTs <= lastTs) {
					break;
				}
				todoMoves.push([hisMv, hisTs]);
			}
			if (todoMoves.length > 1) {
				giikerutil.log('[qiyicube] miss history moves', JSON.stringify(todoMoves), lastTs);
			}
			var toCallback = [];
			var curFacelet;
			for (var i = todoMoves.length - 1; i >= 0; i--) {
				var axis = [4, 1, 3, 0, 2, 5][(todoMoves[i][0] - 1) >> 1];
				var power = [0, 2][todoMoves[i][0] & 1];
				var m = axis * 3 + power;
				mathlib.CubieCube.CubeMult(prevCubie, mathlib.CubieCube.moveCube[m], curCubie);
				prevMoves.unshift("URFDLB".charAt(axis) + " 2'".charAt(power));
				prevMoves = prevMoves.slice(0, 8);
				curFacelet = curCubie.toFaceCube();
				toCallback.push([curFacelet, prevMoves.slice(), [Math.trunc(todoMoves[i][1] / 1.6), locTime], _deviceName]);
				var tmp = curCubie;
				curCubie = prevCubie;
				prevCubie = tmp;
			}
			var newFacelet = parseFacelet(msg.slice(7, 34));
			if (newFacelet != curFacelet) {
				giikerutil.log('[qiyicube] facelet', newFacelet);
				curCubie.fromFacelet(newFacelet);
				GiikerCube.callback(newFacelet, prevMoves, [Math.trunc(ts / 1.6), locTime], _deviceName);
				var tmp = curCubie;
				curCubie = prevCubie;
				prevCubie = tmp;
			} else {
				for (var i = 0; i < toCallback.length; i++) {
					GiikerCube.callback.apply(null, toCallback[i]);
				}
			}
			var newBatteryLevel = msg[35];
			if (newBatteryLevel != batteryLevel) {
				batteryLevel = newBatteryLevel;
				giikerutil.updateBattery([batteryLevel, _deviceName]);
			}
		}
		lastTs = ts;
	}

	$.parseQYData = parseCubeData; // for debug

	function parseFacelet(faceMsg) {
		var ret = [];
		for (var i = 0; i < 54; i++) {
			ret.push("LRDUFB".charAt(faceMsg[i >> 1] >> (i % 2 << 2) & 0xf));
		}
		ret = ret.join("");
		// giikerutil.log('[qiyicube]', 'parsedFacelet', ret);
		return ret;
	}

	function clear() {
		var result = Promise.resolve();
		if (_chrct_cube) {
			_chrct_cube.removeEventListener('characteristicvaluechanged', onCubeEvent);
			result = _chrct_cube.stopNotifications().catch($.noop);
			_chrct_cube = null;
		}
		_service = null;
		_gatt = null;
		_deviceName = null;
		deviceMac = null;
		curCubie = new mathlib.CubieCube();
		prevCubie = new mathlib.CubieCube();
		prevMoves = [];
		lastTs = 0;
		batteryLevel = 0;
		return result;
	}

	GiikerCube.regCubeModel({
		prefix: ['QY-QYSC', 'XMD-TornadoV4-i'],
		init: init,
		opservs: [SERVICE_UUID],
		cics: QIYI_CIC_LIST,
		getBatteryLevel: function() { return Promise.resolve([batteryLevel, _deviceName]); },
		clear: clear
	});
});

这个文件的流程是,用户点击后选择魔方连接,连接成功发送握手包获取蓝牙数据,绑定一个事件,魔方每次转动都会收到加密的蓝牙数据包,发一个然后再按一个数据包,基本流程如下:

蓝牙数据包(msg)
   ↓
校验0xFE头
   ↓
解析opcode(0x02=初始化 / 0x03=转动)
   ↓
┌──────────────┬────────────────────────┐
│ opcode 0x02  │ opcode 0x03            │
│ 初始化魔方    │ 解析旋转动作队列         │
│ 读取电量      │ 更新立方体模型状态       │
│ 解析面片颜色  │ 调用 GiikerCube.callback │
└──────────────┴────────────────────────┘
   ↓
更新全局状态(batteryLevel, lastTs, prevCubie)


三、魔方的数据解析

        从最开始时,是一直获取不到数据的,先是请求ble设备、连接Gatt服务、获取Service、获取Characteristic、最后订阅通知都完成了,却没有任何输出,代码如下:

console.log("蓝牙ble测试");


var _gatt;
var _service;
var _deviceName;
var _chrct_cube;
var UUID_SUFFIX = '-0000-1000-8000-00805f9b34fb';
var SERVICE_UUID = '0000fff0' + UUID_SUFFIX;
var CHRCT_UUID_CUBE = '0000fff6' + UUID_SUFFIX;

var decoder = null;
var deviceMac = 'CC:A3:00:00:D2:D3';
var KEYS = ['NoDg7ANAjGkEwBYCc0xQnADAVgkzGAzHNAGyRTanQi5QIFyHrjQMQgsC6QA'];


window.addEventListener("dblclick", async () =>{    //   双击事件
    console.log("开始连接");

    // 1. 请求 BLE 设备
    const device = await navigator.bluetooth.requestDevice({
    acceptAllDevices: true,
    optionalServices: [SERVICE_UUID] // 这里加上你要访问的所有 service UUID
    });
    console.log('设备:', device.name);

    // 2. 连接 GATT 服务
    const server = await device.gatt.connect();
    console.log('已连接 GATT Server');

    // 3. 获取 Service
    const service = await server.getPrimaryService(SERVICE_UUID);
    console.log('service:\n',service);

    // 4. 获取 Characteristic
    const characteristic  = await service.getCharacteristic(CHRCT_UUID_CUBE);
    console.log('Characteristic:\n', characteristic);

    // 5. 订阅数据通知
    _chrct_cube=await characteristic.startNotifications();
    _chrct_cube.addEventListener('characteristicvaluechanged', onCubeEvent);
    console.log('已订阅数据通知 ✅');


});


  //数据处理函数
  function onCubeEvent(event) {
    console.log("aaaaaaaaaaaaaaaa");

  }

订阅通知有了,但就是不输出,很长一段时间都不知应该如何解决,后面才知它要有握手包,也就是要有await sendHello(deviceMac)这方法才行,而发送与接收这蓝牙魔方的数据,又要用到AES-128 与 LZString压缩/解压缩库,而这种$.sha256 和 $.aes128写法是全局对象上附加功能,要引入jQuery 库,所以最后实现的握手包发送,才得到数据收发,这真的难,index代码:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>BLE 测试</title>
 <style>
    body {
      background-color: #121212; /* 深色背景 */
      color: #ffffff;           /* 浅色文字 */
      font-family: Arial, sans-serif;
    }
    a {
      color: #80cbc4; /* 链接颜色 */
    }
    .card {
      background-color: #1e1e1e;
      padding: 20px;
      border-radius: 10px;
      box-shadow: 0 0 10px rgba(0,0,0,0.5);
    }
  </style>
</head>

<body>
  <button id="connect">连接 BLE 设备</button>
  <script type="text/javascript" src="js/lib/jquery-1.8.0.js"></script>
  <script type="text/javascript" src="js/lib/sha256.js"></script>
  <script type="text/javascript" src="js/lib/lzstring.js"></script>
  <script type="text/javascript" src="js/qiyicube.js"></script>

</body>

</html>

接收到数据
解析到数据

这时的我是兴奋的,但这时我得到只是一串我不懂的数字字符,我一直尝试分析,弄懂它们但这似乎是困难的,而且我制作的版本中有个bug,刚开始接收到数据会一直跳个不停,要用cstimer连接后才能正常输出数据,又不知道怎么解决,后面查明了,在onCubeEvent方法只是获取到了数据,初始化的握手包没有发送,所以魔方发送那里一直询问,也就是这一段:

function parseCubeData(msg) {
		if (msg[0] != 0xfe) {
			console.log('[qiyicube] error cube data', msg);
		}
		var opcode = msg[2];
		var ts = (msg[3] << 24 | msg[4] << 16 | msg[5] << 8 | msg[6]);
		if (opcode == 0x2) { // cube hello
			batteryLevel = msg[35];//电池电量
			sendMessage(msg.slice(2, 7));//不加这个会一直报,初始化握手包

			//初始化魔方
			//var newFacelet = parseFacelet(msg.slice(7, 34));
			//GiikerCube.callback(newFacelet, [], [Math.trunc(ts / 1.6), locTime], _deviceName);
		} 
		else if (opcode == 0x3) { // state change,魔方状态改变
			sendMessage(msg.slice(2, 7));
			console.log("当前旋转动作:",msg[34]);//转一下,log一次
			
		}
}

后面加了这个就可以正常输入与输出了,也不用打开cstimer了,功能也复刻的差不多了,但得到是什么也不知,看来看去发现cstiemr用的是src/js/lib/mathlib.js的魔方数字模型,它们用的是54个面片表示一个魔方的状态,也就是msg.slice(7, 34)它是27 字节,但是有parseFacelet方法可以解析成一个魔方的状态,代码如下:

	function parseFacelet(faceMsg) {
		var ret = [];
		for (var i = 0; i < 54; i++) {
			ret.push("LRDUFB".charAt(faceMsg[i >> 1] >> (i % 2 << 2) & 0xf));
		}
		ret = ret.join("");
		// giikerutil.log('[qiyicube]', 'parsedFacelet', ret);
		return ret;
	}

同样的mathlib中有定义:

所以,可以得到这样的魔方状态图:

而当我正想着如将这个魔方状态,也就是54面片转换成(F/R/U/L/D/B)等旋转操作时,也就是最先我想不出去问原作者的问题,也就是连5次U的操作,也让人很难分析:


BUBUUU LUFURUR RR RR B UFR FFF DFFLDD DDDDDRUL FLLLLLFR BLBBBDBB
BUBUUU FLFLRUU RR FR B DFU FFF FFRRRU DDDDDRUL LLLDLLDR BLBBBDBB
BUBUUU DDLFRUL RR FR B FFD FFF RFUFUL DDDDDRUL RLLRLLUR BLBBBDBB
BUBUUU URRDRUD RR LR B RFF FFF UFDFLF DDDDDRUL FLLULLLR BLBBBDBB
BUBUUU LUFURUR RR RR B UFR FFF DFFLDD DDDDDRUL FLLLLLFR BLBBBDBB

难度太大了,不知是怎么定义的,结果搞了半天人家,魔方传过来的数据是直接有操作的,也就是F/R/U/L/D/B它是编好号的,直接读取就行了,压根不用算,这波真是无语了,而对应的是parseCubeData函数中的msg[34],也就是这段:

function parseCubeData(msg) {
		var locTime = $.now();
		if (msg[0] != 0xfe) {
			giikerutil.log('[qiyicube] error cube data', msg);
			return;
		}
		var opcode = msg[2];
		var ts = (msg[3] << 24 | msg[4] << 16 | msg[5] << 8 | msg[6]);
		if (opcode == 0x2) { // cube hello
			batteryLevel = msg[35];
			sendMessage(msg.slice(2, 7));
			var newFacelet = parseFacelet(msg.slice(7, 34));
			GiikerCube.callback(newFacelet, [], [Math.trunc(ts / 1.6), locTime], _deviceName);
			prevCubie.fromFacelet(newFacelet);
			if (newFacelet != kernel.getProp('giiSolved', mathlib.SOLVED_FACELET)) {
				var rst = kernel.getProp('giiRST');
				if (rst == 'a' || rst == 'p' && confirm(CONFIRM_GIIRST)) {
					giikerutil.markSolved();
				}
			}
		} else if (opcode == 0x3) { // state change
			sendMessage(msg.slice(2, 7));
			// check timestamps
			var todoMoves = [[msg[34], ts]];
			while (todoMoves.length < 10) {
				var off = 91 - 5 * todoMoves.length;
				var hisTs = (msg[off] << 24 | msg[off + 1] << 16 | msg[off + 2] << 8 | msg[off + 3]);
				var hisMv = msg[off + 4];
				if (hisTs <= lastTs) {
					break;
				}
				todoMoves.push([hisMv, hisTs]);
			}
			if (todoMoves.length > 1) {
				giikerutil.log('[qiyicube] miss history moves', JSON.stringify(todoMoves), lastTs);
			}
			var toCallback = [];
			var curFacelet;
			for (var i = todoMoves.length - 1; i >= 0; i--) {
				var axis = [4, 1, 3, 0, 2, 5][(todoMoves[i][0] - 1) >> 1];
				var power = [0, 2][todoMoves[i][0] & 1];
				var m = axis * 3 + power;
				mathlib.CubieCube.CubeMult(prevCubie, mathlib.CubieCube.moveCube[m], curCubie);
				prevMoves.unshift("URFDLB".charAt(axis) + " 2'".charAt(power));
				prevMoves = prevMoves.slice(0, 8);
				curFacelet = curCubie.toFaceCube();
				toCallback.push([curFacelet, prevMoves.slice(), [Math.trunc(todoMoves[i][1] / 1.6), locTime], _deviceName]);
				var tmp = curCubie;
				curCubie = prevCubie;
				prevCubie = tmp;
			}
			var newFacelet = parseFacelet(msg.slice(7, 34));
			if (newFacelet != curFacelet) {
				giikerutil.log('[qiyicube] facelet', newFacelet);
				curCubie.fromFacelet(newFacelet);
				GiikerCube.callback(newFacelet, prevMoves, [Math.trunc(ts / 1.6), locTime], _deviceName);
				var tmp = curCubie;
				curCubie = prevCubie;
				prevCubie = tmp;
			} else {
				for (var i = 0; i < toCallback.length; i++) {
					GiikerCube.callback.apply(null, toCallback[i]);
				}
			}
			var newBatteryLevel = msg[35];
			if (newBatteryLevel != batteryLevel) {
				batteryLevel = newBatteryLevel;
				giikerutil.updateBattery([batteryLevel, _deviceName]);
			}
		}
		lastTs = ts;
	}

它可以回溯最多 9 个历史动作,当前动作是msg[34],所以可以得到:

F  绿 9、10
R  红 3、4
U     白  7、8
D     黄  5、6

L     橙   1、2
B     蓝  11、12
其中偶数为顺时针、奇数为逆时针,若是E/M/S是上面两个组合

所以数据包是:

  • 第一个字节(msg[0])应当是固定的同步头 0xFE,不合法,直接返回
  • 第二个字节(msg[2])是操作码,0x02:魔方上电/初始化(Hello 包)及 0x03:魔方状态改变(转动事件)
  • msg.slice(2,7),向魔方握手回应消息
  • msg.slice(7, 34),从第 7~33 字节是面片颜色编码(共 27 字节),可解析成54面片
  • msg[34],读取出当前旋转动作
  • msg[35],读取出电池电量

其中,parseCubeData函数中是接收与旋转魔方的地方,如何注释了,那只会接收数据而虚拟魔方不会转动,也就下面这三行代码:

prevMoves = prevMoves.slice(0, 8);//历史动作
				curFacelet = curCubie.toFaceCube();
				toCallback.push([curFacelet, prevMoves.slice(), [Math.trunc(todoMoves[i][1] / 1.6), locTime], _deviceName]);


for (var i = 0; i < toCallback.length; i++) {
   GiikerCube.callback.apply(null, toCallback[i]);//重放所有动作
}

这样最后,得到了一个文件,就可以正确读取魔方状态了,Demo地址:test-ble
 

console.log("hello world~!");


var _gatt;
var _service;
var _deviceName;
var _chrct_cube;
var UUID_SUFFIX = '-0000-1000-8000-00805f9b34fb';
var SERVICE_UUID = '0000fff0' + UUID_SUFFIX;
var CHRCT_UUID_CUBE = '0000fff6' + UUID_SUFFIX;

let aesEcb;

var QIYI_CIC_LIST = [0x0504];

var decoder = null;
var deviceMac = 'CC:A3:00:00:D2:D3';
var KEYS = ['NoDg7ANAjGkEwBYCc0xQnADAVgkzGAzHNAGyRTanQi5QIFyHrjQMQgsC6QA'];

// js/test.js
document.addEventListener('DOMContentLoaded', () => {
  clear();
  const btn = document.getElementById('connect');

  btn.addEventListener('click', async () => {



    try {


      // 1. 请求 BLE 设备
      const device = await navigator.bluetooth.requestDevice({
        acceptAllDevices: true,
        optionalServices: [SERVICE_UUID] // 这里加上你要访问的所有 service UUID
      });
      console.log('设备:', device.name);

      // 2. 连接 GATT 服务
      const server = await device.gatt.connect();
      console.log('已连接 GATT Server');


      // 3. 获取 Battery Service
      const service = await server.getPrimaryService(SERVICE_UUID);
      console.log('service:\n',service);

      // 4. 获取 Characteristic
      const characteristic  = await service.getCharacteristic(CHRCT_UUID_CUBE);
      console.log('Characteristic:\n', characteristic);





      // 5. 订阅数据通知
      _chrct_cube=await characteristic.startNotifications();
      _chrct_cube.addEventListener('characteristicvaluechanged', onCubeEvent);
      console.log('已订阅数据通知 ✅');

      deviceMac = 'CC:A3:00:00:D2:D3';
      await sendHello(deviceMac);

    } catch (error) {
      console.error(error);
    }
  });
});

  // 🔹 数据处理函数
  function onCubeEvent(event) {
    const value = event.target.value;
	//得到加密数据
    const encMsg = new Uint8Array(value.buffer);

    // 初始化 AES-128 解密器
    if (!decoder) {
      const key = JSON.parse(LZString.decompressFromEncodedURIComponent(KEYS[0]));// 假设 KEYS[0] 是压缩的密钥字符串,需解压为 16 字节数组
      decoder = $.aes128(key); // 创建 AES-128 实例
    }

	//解密msg
    var msg = [];
	for (var i = 0; i < encMsg.length; i += 16) {
		var block = encMsg.slice(i, i + 16);
		decoder.decrypt(block);
		for (var j = 0; j < 16; j++) {
			msg[i + j] = block[j];
		}
	}
	console.log('[qiyicube] decrypted msg', msg);

	//处理解密后魔方状态的数据,
	parseCubeData(msg);
  }


  function sendHello(mac) {
    if (!mac) {
      return Promise.reject('empty mac');
    }
    var content = [0x00, 0x6b, 0x01, 0x00, 0x00, 0x22, 0x06, 0x00, 0x02, 0x08, 0x00];
    for (var i = 5; i >= 0; i--) {
      content.push(parseInt(mac.slice(i * 3, i * 3 + 2), 16));
    }
    return sendMessage(content);
  }

  //使用的 CRC16 校验算法,确保发送和接收的数据没有被损坏
  function crc16modbus(data) {
		var crc = 0xFFFF;
		for (var i = 0; i < data.length; i++) {
			crc ^= data[i];
			for (var j = 0; j < 8; j++) {
				crc = (crc & 0x1) > 0 ? (crc >> 1) ^ 0xa001 : crc >> 1;
			}
		}
		return crc;
	}


  	// content: [u8, u8, ..]
	function sendMessage(content) {
		// if (!_chrct_cube || DEBUGBL) {
		// 	return DEBUGBL ? Promise.resolve() : Promise.reject();
		// }
		var msg = [0xfe];
		msg.push(4 + content.length); // length = 1 (op) + cont.length + 2 (crc)
		for (var i = 0; i < content.length; i++) {
			msg.push(content[i]);
		}
		var crc = crc16modbus(msg);
		msg.push(crc & 0xff, crc >> 8);
		var npad = (16 - msg.length % 16) % 16;
		for (var i = 0; i < npad; i++) {
			msg.push(0);
		}
		var encMsg = [];
		decoder = decoder || $.aes128(JSON.parse(LZString.decompressFromEncodedURIComponent(KEYS[0])));
		for (var i = 0; i < msg.length; i += 16) {
			var block = msg.slice(i, i + 16);
			decoder.encrypt(block);
			for (var j = 0; j < 16; j++) {
				encMsg[i + j] = block[j];
			}
		}
		console.log('[qiyicube] send message to cube', msg, encMsg);
		return _chrct_cube.writeValue(new Uint8Array(encMsg).buffer);
	}


	function clear() {
		var result = Promise.resolve();
		if (_chrct_cube) {
			_chrct_cube.removeEventListener('characteristicvaluechanged', onCubeEvent);
			result = _chrct_cube.stopNotifications().catch($.noop);
			_chrct_cube = null;
		}
		_service = null;
		_gatt = null;
		_deviceName = null;
		deviceMac = null;
		// curCubie = new mathlib.CubieCube();
		// prevCubie = new mathlib.CubieCube();
		prevMoves = [];
		lastTs = 0;
		batteryLevel = 0;
		return result;
	}


function parseCubeData(msg) {
		var locTime = $.now();
		if (msg[0] != 0xfe) {
			console.log('[qiyicube] error cube data', msg);
		}
		var opcode = msg[2];
		var ts = (msg[3] << 24 | msg[4] << 16 | msg[5] << 8 | msg[6]);
		if (opcode == 0x2) { // cube hello,不加这个会直报
			batteryLevel = msg[35];
			sendMessage(msg.slice(2, 7));

			//初始化魔方
			//var newFacelet = parseFacelet(msg.slice(7, 34));
			//GiikerCube.callback(newFacelet, [], [Math.trunc(ts / 1.6), locTime], _deviceName);
			//prevCubie.fromFacelet(newFacelet);
			// if (newFacelet != kernel.getProp('giiSolved', mathlib.SOLVED_FACELET)) {
			// 	var rst = kernel.getProp('giiRST');
			// 	if (rst == 'a' || rst == 'p' && confirm(CONFIRM_GIIRST)) {
			// 		giikerutil.markSolved();
			// 	}
			// }
		} 
		else if (opcode == 0x3) { // state change,魔方状态改变
			sendMessage(msg.slice(2, 7));
			console.log("当前旋转动作:",msg[34]);
			
		
		
		
		
		
		
		}
}

最后给出一个可以连接奇异魔方的魔改案例,这个可以连接手上的魔方,跟cstimer魔方转动效果差不多,使用结果:

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐