UNPKG

gan-i3-356-bluetooth

Version:

Library for connecting to and interacting with Bluetooth-enabled Rubik's cubes (GAN)

1 lines 17.1 kB
import*as LZString from"lz-string";import{mathlib}from"./mathlib";import{AES128}from"./aes128";import{matchUUID,EventEmitter}from"./utils";import{CubeNumberConverter}from"./cubenum";export class GanCube extends EventEmitter{constructor(){super(),this.debug=!0,this.props={},this._device=null,this._gatt=null,this._service_data=null,this._service_meta=null,this._chrct_f2=null,this._chrct_f5=null,this._chrct_f6=null,this._chrct_f7=null,this._service_v2data=null,this._chrct_v2read=null,this._chrct_v2write=null,this.UUID_SUFFIX="-0000-1000-8000-00805f9b34fb",this.SERVICE_UUID_META=`0000180a${this.UUID_SUFFIX}`,this.CHRCT_UUID_VERSION=`00002a28${this.UUID_SUFFIX}`,this.CHRCT_UUID_HARDWARE=`00002a23${this.UUID_SUFFIX}`,this.SERVICE_UUID_DATA=`0000fff0${this.UUID_SUFFIX}`,this.CHRCT_UUID_F2=`0000fff2${this.UUID_SUFFIX}`,this.CHRCT_UUID_F3=`0000fff3${this.UUID_SUFFIX}`,this.CHRCT_UUID_F5=`0000fff5${this.UUID_SUFFIX}`,this.CHRCT_UUID_F6=`0000fff6${this.UUID_SUFFIX}`,this.CHRCT_UUID_F7=`0000fff7${this.UUID_SUFFIX}`,this.SERVICE_UUID_V2DATA="6e400001-b5a3-f393-e0a9-e50e24dc4179",this.CHRCT_UUID_V2READ="28be4cb6-cd67-11e9-a32f-2a2ae2dbcce4",this.CHRCT_UUID_V2WRITE="28be4a4a-cd67-11e9-a32f-2a2ae2dbcce4",this.GAN_CIC_LIST=mathlib.valuedArray(256,(i=>i<<8|1)),this.decoder=null,this.deviceName=null,this.deviceMac=null,this.KEYS=["NoRgnAHANATADDWJYwMxQOxiiEcfYgSK6Hpr4TYCs0IG1OEAbDszALpA","NoNg7ANATFIQnARmogLBRUCs0oAYN8U5J45EQBmFADg0oJAOSlUQF0g","NoRgNATGBs1gLABgQTjCeBWSUDsYBmKbCeMADjNnXxHIoIF0g","NoRg7ANAzBCsAMEAsioxBEIAc0Cc0ATJkgSIYhXIjhMQGxgC6QA","NoVgNAjAHGBMYDYCcdJgCwTFBkYVgAY9JpJYUsYBmAXSA","NoRgNAbAHGAsAMkwgMyzClH0LFcArHnAJzIqIBMGWEAukA"],this.prevMoves=[],this.timeOffs=[],this.moveBuffer=[],this.prevCubie=new mathlib.CubieCube,this.curCubie=new mathlib.CubieCube,this.latestFacelet=mathlib.SOLVED_FACELET,this.deviceTime=0,this.deviceTimeOffset=0,this.moveCnt=-1,this.prevMoveCnt=-1,this.movesFromLastCheck=1e3,this.batteryLevel=100,this.keyCheck=0}getProp(key,def){return this.props.hasOwnProperty(key)?this.props[key]:def}setProp(key,value){this.props[key]=value}getKey(version,value){let key=this.KEYS[version>>8&255];if(!key)return;const parsedKey=JSON.parse(LZString.decompressFromEncodedURIComponent(key));for(let i=0;i<6;i++)parsedKey[i]=parsedKey[i]+value.getUint8(5-i)&255;return parsedKey}getKeyV2(value,ver=0){const key=JSON.parse(LZString.decompressFromEncodedURIComponent(this.KEYS[2+2*ver])),iv=JSON.parse(LZString.decompressFromEncodedURIComponent(this.KEYS[3+2*ver]));for(let i=0;i<6;i++)key[i]=(key[i]+value[5-i])%255,iv[i]=(iv[i]+value[5-i])%255;return[key,iv]}decode(value){const ret=[];for(let i=0;i<value.byteLength;i++)ret[i]=value.getUint8(i);if(null==this.decoder)return ret;const iv=this.decoder.iv||[];if(ret.length>16){const offset=ret.length-16,block=this.decoder.decrypt(ret.slice(offset));for(let i=0;i<16;i++)ret[i+offset]=block[i]^iv[i]}this.decoder.decrypt(ret);for(let i=0;i<16;i++)ret[i]^=~~iv[i];return ret}encode(ret){if(null==this.decoder)return ret;const iv=this.decoder.iv||[];for(let i=0;i<16;i++)ret[i]^=~~iv[i];if(this.decoder.encrypt(ret),ret.length>16){const offset=ret.length-16,block=ret.slice(offset);for(let i=0;i<16;i++)block[i]^=~~iv[i];this.decoder.encrypt(block);for(let i=0;i<16;i++)ret[i+offset]=block[i]}return ret}getManufacturerDataBytes(mfData){if(mfData instanceof DataView)return mfData;for(const id of this.GAN_CIC_LIST)if(mfData.has(id))return this.debug&&console.log(`[gancube] found Manufacturer Data under CIC = 0x${id.toString(16).padStart(4,"0")}`),mfData.get(id);this.debug&&console.log("[gancube] Looks like this cube has new unknown CIC")}waitForAdvs(){if(!this._device?.watchAdvertisements)return Promise.reject(-1);const abortController=new AbortController;return new Promise(((resolve,reject)=>{const onAdvEvent=event=>{this.debug&&console.log("[gancube] receive adv event",event);const mfData=event.manufacturerData,dataView=this.getManufacturerDataBytes(mfData);if(dataView&&dataView.byteLength>=6){const mac=[];for(let i=0;i<6;i++)mac.push((dataView.getUint8(dataView.byteLength-i-1)+256).toString(16).slice(1));this._device&&this._device.removeEventListener("advertisementreceived",onAdvEvent),abortController.abort(),resolve(mac.join(":"))}};this._device&&this._device.addEventListener("advertisementreceived",onAdvEvent),this._device&&this._device.watchAdvertisements&&this._device.watchAdvertisements({signal:abortController.signal}),setTimeout((()=>{this._device?.removeEventListener("advertisementreceived",onAdvEvent),abortController.abort(),reject(-2)}),1e4)}))}v2initKey(forcePrompt,ver,providedMac){if(this.deviceMac){const savedMacMap=JSON.parse(this.getProp("giiMacMap","{}")),prevMac=this.deviceName?savedMacMap[this.deviceName]:void 0;prevMac&&this.deviceName&&prevMac.toUpperCase()===this.deviceMac.toUpperCase()?this.debug&&console.log("[gancube] v2init mac matched"):(this.debug&&console.log("[gancube] v2init mac updated"),this.deviceName&&(savedMacMap[this.deviceName]=this.deviceMac,this.setProp("giiMacMap",JSON.stringify(savedMacMap)))),this.v2initDecoder(this.deviceMac,ver)}else{const savedMacMap=JSON.parse(this.getProp("giiMacMap","{}"));let mac=this.deviceName?savedMacMap[this.deviceName]:void 0;if((!mac||forcePrompt||providedMac)&&(mac=providedMac),!mac)return this.debug&&console.log("[gancube] No MAC address provided"),void(this.decoder=null);if(!/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.exec(mac))return void(this.decoder=null);this.deviceName&&mac!==savedMacMap[this.deviceName]&&(savedMacMap[this.deviceName]=mac,this.setProp("giiMacMap",JSON.stringify(savedMacMap))),this.v2initDecoder(mac,ver)}}v2initDecoder(mac,ver){const value=[];for(let i=0;i<6;i++)value.push(parseInt(mac.slice(3*i,3*i+2),16));const keyiv=this.getKeyV2(value,ver);this.debug&&console.log("[gancube] ver=",ver," key=",JSON.stringify(keyiv)),this.decoder=new AES128(keyiv[0]),this.decoder.iv=keyiv[1]}v2sendRequest(req){if(!this._chrct_v2write)return void(this.debug&&console.log("[gancube] v2sendRequest cannot find v2write chrct"));const encodedReq=this.encode(req.slice());return this.debug&&console.log("[gancube] v2sendRequest",req,encodedReq),this._chrct_v2write.writeValue(new Uint8Array(encodedReq).buffer)}v2sendSimpleRequest(opcode){const req=mathlib.valuedArray(20,0);return req[0]=opcode,this.v2sendRequest(req)}v2requestFacelets(){return this.v2sendSimpleRequest(4)}v2requestBattery(){return this.v2sendSimpleRequest(9)}v2requestHardwareInfo(){return this.v2sendSimpleRequest(5)}v2requestReset(){return this.v2sendRequest([10,5,57,119,0,0,1,35,69,103,137,171,0,0,0,0,0,0,0,0])}v2init(ver,macAddress){return this.debug&&console.log("[gancube] v2init start"),this.keyCheck=0,this.v2initKey(!0,ver,macAddress),this._service_v2data?this._service_v2data.getCharacteristics().then((chrcts=>{this.debug&&console.log("[gancube] v2init find chrcts",chrcts);for(let i=0;i<chrcts.length;i++){const chrct=chrcts[i];this.debug&&console.log("[gancube] v2init find chrct",chrct.uuid),matchUUID(chrct.uuid,this.CHRCT_UUID_V2READ)?this._chrct_v2read=chrct:matchUUID(chrct.uuid,this.CHRCT_UUID_V2WRITE)&&(this._chrct_v2write=chrct)}this._chrct_v2read||this.debug&&console.log("[gancube] v2init cannot find v2read chrct")})).then((()=>{if(this.debug&&console.log("[gancube] v2init v2read start notifications"),!this._chrct_v2read)throw new Error("V2 read characteristic not found");return this._chrct_v2read.startNotifications()})).then((()=>{if(this.debug&&console.log("[gancube] v2init v2read notification started"),!this._chrct_v2read)throw new Error("V2 read characteristic not found");return this._chrct_v2read.addEventListener("characteristicvaluechanged",this.onStateChangedV2.bind(this))})).then((()=>this.v2requestHardwareInfo())).then((()=>this.v2requestFacelets())).then((()=>this.v2requestBattery())):Promise.reject(new Error("V2 service not found"))}onStateChangedV2(event){const{value:value}=event.target;null!=this.decoder&&this.parseV2Data(value)}parseV2Data(value){const locTime=Date.now();value=this.decode(value);for(var i=0;i<value.length;i++)value[i]=(value[i]+256).toString(2).slice(1);value=value.join("");const mode=parseInt(value.slice(0,4),2);if(1==mode){const gyroX=parseInt(value.slice(8,16),2)-128,gyroY=parseInt(value.slice(16,24),2)-128,gyroZ=parseInt(value.slice(24,32),2)-128;this.emit("gyroData",{x:gyroX,y:gyroY,z:gyroZ,timestamp:locTime})}else if(2==mode){if(this.moveCnt=parseInt(value.slice(4,12),2),this.moveCnt==this.prevMoveCnt||-1==this.prevMoveCnt)return;this.timeOffs=[],this.prevMoves=[];let keyChkInc=0;for(i=0;i<7;i++){const m=parseInt(value.slice(12+5*i,17+5*i),2);this.timeOffs[i]=parseInt(value.slice(47+16*i,63+16*i),2),this.prevMoves[i]="URFDLB".charAt(m>>1)+" '".charAt(1&m),m>=12&&(this.prevMoves[i]="U ",keyChkInc=1)}this.keyCheck+=keyChkInc,0==keyChkInc&&(this.debug&&console.log("V2 Protocol - Moves to be processed:",this.prevMoves),this.updateMoveTimes(locTime,!0),this.debug&&console.log("V2 Protocol - Current cube state after all moves:",this.prevCubie.faceletToNumber(this.prevCubie.toFaceCube())))}else if(4==mode){this.moveCnt=parseInt(value.slice(4,12),2);const cc=new mathlib.CubieCube;let echk=0,cchk=3840;for(i=0;i<7;i++){var perm=parseInt(value.slice(12+3*i,15+3*i),2);cchk-=(ori=parseInt(value.slice(33+2*i,35+2*i),2))<<3,cchk^=perm,cc.ca[i]=ori<<3|perm}cc.ca[7]=(4088&cchk)%24|7&cchk;for(i=0;i<11;i++){var ori;echk^=(perm=parseInt(value.slice(47+4*i,51+4*i),2))<<1|(ori=parseInt(value.slice(91+i,92+i),2)),cc.ea[i]=perm<<1|ori}cc.ea[11]=echk,this.latestFacelet=cc.toFaceCube(),this.debug&&console.log("[gancube]","v2 facelets event state parsed",this.latestFacelet),this.prevCubie=new mathlib.CubieCube,this.prevCubie.ca=[...cc.ca],this.prevCubie.ea=[...cc.ea],this.emit("cubeStateChanged",{facelet:this.latestFacelet,corners:[...cc.ca],edges:[...cc.ea],timestamp:Date.now()}),"LLUDULLUDRFFURUBBFDRBBFFFRULFRFDRFBLBLBDLLDDURUUBBRDDR"===this.latestFacelet?(this.debug&&console.log("SOLVED"),this.emit("cubeSolved",{})):this.emit("unSolved",{}),-1==this.prevMoveCnt&&this.initCubeState()}else if(5==mode){this.debug&&console.log("[gancube]","v2 received hardware info event",value);const hardwareVersion=`${parseInt(value.slice(8,16),2)}.${parseInt(value.slice(16,24),2)}`,softwareVersion=`${parseInt(value.slice(24,32),2)}.${parseInt(value.slice(32,40),2)}`;let devName="";for(i=0;i<8;i++)devName+=String.fromCharCode(parseInt(value.slice(40+8*i,48+8*i),2));const gyroEnabled=1===parseInt(value.slice(104,105),2);this.debug&&console.log("[gancube]","Hardware Version",hardwareVersion),this.debug&&console.log("[gancube]","Software Version",softwareVersion),this.debug&&console.log("[gancube]","Device Name",devName),this.debug&&console.log("[gancube]","Gyro Enabled",gyroEnabled)}else 9==mode?(this.debug&&console.log("[gancube]","v2 received battery event",value),this.batteryLevel=parseInt(value.slice(8,16),2)):this.debug&&console.log("[gancube]","v2 received unknown event",value)}updateMoveTimes(locTime,isV2){let moveDiff=this.moveCnt-this.prevMoveCnt&255;this.debug&&moveDiff>1&&console.log("[gancube]",`bluetooth event was lost, moveDiff = ${moveDiff}`),this.prevMoveCnt=this.moveCnt,this.movesFromLastCheck+=moveDiff,moveDiff>this.prevMoves.length&&(this.movesFromLastCheck=50,moveDiff=this.prevMoves.length);let calcTs=this.deviceTime+this.deviceTimeOffset;for(var i=moveDiff-1;i>=0;i--)calcTs+=this.timeOffs[i];(!this.deviceTime||Math.abs(locTime-calcTs)>2e3)&&(this.debug&&console.log("[gancube]","time adjust",locTime-calcTs,"@",locTime),this.deviceTime+=locTime-calcTs);for(i=moveDiff-1;i>=0;i--){const m=3*"URFDLB".indexOf(this.prevMoves[i][0])+" 2'".indexOf(this.prevMoves[i][1]);mathlib.CubieCube.EdgeMult(this.prevCubie,mathlib.CubieCube.moveCube[m],this.curCubie),mathlib.CubieCube.CornMult(this.prevCubie,mathlib.CubieCube.moveCube[m],this.curCubie),this.deviceTime+=this.timeOffs[i],this.debug&&console.log(`Move applied: ${this.prevMoves[i]}`),this.debug&&console.log(`Cube state after move: ${this.curCubie.toFaceCube()}`),this.emit("cubeStateChanged",{facelet:this.curCubie.toFaceCube(),move:this.prevMoves[i],corners:[...this.curCubie.ca],edges:[...this.curCubie.ea],timestamp:this.deviceTime});const tmp=this.curCubie;this.curCubie=this.prevCubie,this.prevCubie=tmp,this.emit("move",{move:this.prevMoves[i],time:this.timeOffs[i]}),this.debug&&console.log("[gancube] move",this.prevMoves[i],this.timeOffs[i])}this.deviceTimeOffset=locTime-this.deviceTime}initCubeState(){Date.now();if(this.debug&&console.log("[gancube]","init cube state"),this.prevCubie.fromFacelet(this.latestFacelet),this.prevMoveCnt=this.moveCnt,this.latestFacelet!=this.getProp("giiSolved",mathlib.SOLVED_FACELET)){this.getProp("giiRST","")}}clear(){let result=Promise.resolve();return this._chrct_v2read&&(this._chrct_v2read.removeEventListener("characteristicvaluechanged",this.onStateChangedV2),result=this._chrct_v2read.stopNotifications().catch((()=>{})),this._chrct_v2read=null),this._service_data=null,this._service_meta=null,this._service_v2data=null,this._gatt=null,this.deviceName=null,this.deviceMac=null,this.prevMoves=[],this.timeOffs=[],this.moveBuffer=[],this.prevCubie=new mathlib.CubieCube,this.curCubie=new mathlib.CubieCube,this.latestFacelet=mathlib.SOLVED_FACELET,this.deviceTime=0,this.deviceTimeOffset=0,this.moveCnt=-1,this.prevMoveCnt=-1,this.movesFromLastCheck=1e3,this.batteryLevel=100,this.clearAllListeners(),result}init(device,macAddress){return this.clear(),this.deviceName=device.name||null,this._device=device,this.debug&&console.log("[gancube] init gan cube start"),this.waitForAdvs().then((mac=>{this.debug&&console.log(`[gancube] init, found cube bluetooth hardware MAC = ${mac}`),this.deviceMac=mac}),(err=>{this.debug&&console.log(`[gancube] init, unable to automatically determine cube MAC, error code = ${err}`)})).then((()=>{if(!device.gatt)throw new Error("Bluetooth GATT not available");return device.gatt.connect()})).then((gatt=>(this._gatt=gatt,gatt.getPrimaryServices()))).then((services=>{for(let i=0;i<services.length;i++){const service=services[i];this.debug&&console.log("[gancube] checkHardware find service",service.uuid),matchUUID(service.uuid,this.SERVICE_UUID_META)?this._service_meta=service:matchUUID(service.uuid,this.SERVICE_UUID_DATA)?this._service_data=service:matchUUID(service.uuid,this.SERVICE_UUID_V2DATA)&&(this._service_v2data=service)}if(this._service_v2data)return this.v2init((this.deviceName||"").startsWith("AiCube")?1:0,macAddress);throw new Error("Wrong cube :(")}))}checkState(){return this.movesFromLastCheck<50?Promise.resolve(!1):this._chrct_f2?this._chrct_f2.readValue().then((value=>{const decodedValue=this.decode(value),state=[];for(let i=0;i<decodedValue.length-2;i+=3){const face=decodedValue[1^i]<<16|decodedValue[i+1^1]<<8|decodedValue[i+2^1];for(let j=21;j>=0;j-=3)state.push("URFDLB".charAt(face>>j&7)),12==j&&state.push("URFDLB".charAt(i/3))}return this.latestFacelet=state.join(""),this.movesFromLastCheck=0,-1!=this.prevMoveCnt||(this.initCubeState(),!1)})):Promise.resolve(!1)}loopRead(){if(this._device&&this._chrct_f5)return this._chrct_f5.readValue().then((value=>{const decodedValue=this.decode(value),locTime=Date.now();if(this.moveCnt=decodedValue[12],this.moveCnt==this.prevMoveCnt)return Promise.resolve();this.prevMoves=[];for(let i=0;i<6;i++){const m=decodedValue[13+i];this.prevMoves.unshift("URFDLB".charAt(~~(m/3))+" 2'".charAt(m%3))}let f6val=[];return this._chrct_f6?this._chrct_f6.readValue().then((value=>(f6val=this.decode(value),this.checkState()))).then((isUpdated=>{if(isUpdated)return this.debug&&console.log("[gancube]","facelet state calc",this.prevCubie.toFaceCube()),this.debug&&console.log("[gancube]","facelet state read",this.latestFacelet),void(this.prevCubie.toFaceCube()!=this.latestFacelet&&this.debug&&console.log("[gancube]","Cube state check error"));this.timeOffs=[];for(let i=0;i<9;i++){const off=f6val[2*i+1]|f6val[2*i+2]<<8||0;this.timeOffs.unshift(off)}return this.updateMoveTimes(locTime,!1),Promise.resolve()})):Promise.resolve()})).then((()=>this.loopRead()))}getBatteryLevel(){return this._gatt?this._service_v2data?Promise.resolve([this.batteryLevel,`${this.deviceName}*`]):this._chrct_f7?this._chrct_f7.readValue().then((value=>{const decodedValue=this.decode(value);return Promise.resolve([decodedValue[7],this.deviceName||""])})):Promise.resolve([this.batteryLevel,this.deviceName||""]):Promise.reject("Bluetooth Cube is not connected")}logCubeState(){return console.log("=== Current Cube State ==="),console.log("Facelet Representation:",this.prevCubie.toFaceCube()),console.log("Corner Array:",this.prevCubie.ca),console.log("Edge Array:",this.prevCubie.ea),console.log("========================"),{facelet:this.prevCubie.toFaceCube(),corners:[...this.prevCubie.ca],edges:[...this.prevCubie.ea]}}get opservs(){return[this.SERVICE_UUID_DATA,this.SERVICE_UUID_META,this.SERVICE_UUID_V2DATA]}get cics(){return this.GAN_CIC_LIST}get facelets(){return this.latestFacelet}getStateHex(){return CubeNumberConverter.cubeNumberToHex(CubeNumberConverter.cubeStateToNumber(this.prevCubie.ca,this.prevCubie.ea))}}