var SERVICE = { DEVICE_INFO: 'FFE0', DATA_COMM: 'FFE1', OTA: 'FFE2' } var CHAR = { DEVICE_INFO: 'FFE3', COMMAND: 'FFE4', STATUS: 'FFE5', BOND_INFO: 'FFE6', OTA_CONTROL: 'FFE7', OTA_DATA: 'FFE8', OTA_STATUS: 'FFE9' } var CMD = { SET_PARAMS: 0x01, START: 0x02, STOP: 0x03, QUERY_STATUS: 0x04, BIND: 0x05, UNBIND: 0x06 } var NOTIFY = { STATUS_REPORT: 0x21, ACK: 0x22, TREATMENT_COMPLETE: 0x31, EXCEPTION: 0x32, BIND_SUCCESS: 0x33 } var MODE_STATE = { IDLE: 0x00, SCANNING: 0x01, ACTIVE: 0x02, PAUSED: 0x03, COMPLETED: 0x04, ERROR: 0x05, OTA: 0x06 } var WAVELENGTH = { IR: 1, R: 2, UV: 3, Y: 4 } var TREAT_MODE = { NORMAL: 0, SMART: 1 } var REGION = { LEFT_CHEEK: 0x01, RIGHT_CHEEK: 0x02, FOREHEAD: 0x04, CHIN: 0x08, NOSE: 0x10, LEFT_EYE: 0x20, RIGHT_EYE: 0x40, FULL_FACE: 0x7F } var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye'] var DEVICE_ERR = { 0x00: 'SUCCESS', 0x01: 'ERR_REGION_INVALID', 0x02: 'ERR_REGION_EMPTY', 0x03: 'ERR_BRIGHTNESS_INVALID', 0x04: 'ERR_DURATION_INVALID', 0x05: 'ERR_NOT_BOUND', 0x06: 'ERR_NO_SUBSCRIPTION', 0x07: 'ERR_TEMP_HIGH', 0x08: 'ERR_BATTERY_LOW', 0x09: 'ERR_ALREADY_RUNNING', 0x0A: 'ERR_NOT_RUNNING', 0x0B: 'ERR_OTA_FAILED', 0x0C: 'ERR_BLE_DISCONNECTED' } var _deviceId = null var _connected = false var _chars = {} var _cmdSeq = 0 var _pendingAcks = {} var _listeners = {} function nextSeq() { _cmdSeq = (_cmdSeq + 1) % 256 return _cmdSeq } function bufferToBytes(buffer) { var arr = new Uint8Array(buffer) var bytes = [] for (var i = 0; i < arr.length; i++) { bytes.push(arr[i]) } return bytes } function bytesToBuffer(bytes) { var buffer = new ArrayBuffer(bytes.length) var view = new Uint8Array(buffer) for (var i = 0; i < bytes.length; i++) { view[i] = bytes[i] } return buffer } function xorChecksum(bytes) { var result = 0 for (var i = 0; i < bytes.length; i++) { result ^= bytes[i] } return result } function buildFrame(type, payload) { var len = payload ? payload.length : 0 var frame = [0xAA, 0x55, len, type] if (payload && payload.length > 0) { frame = frame.concat(payload) } var checkBytes = frame.slice(0) frame.push(xorChecksum(checkBytes)) return bytesToBuffer(frame) } function parseFrame(buffer) { var bytes = bufferToBytes(buffer) if (bytes.length < 5) return null if (bytes[0] !== 0xAA || bytes[1] !== 0x55) return null var len = bytes[2] if (bytes.length < 5 + len) return null var type = bytes[3] var payload = bytes.slice(4, 4 + len) var checksum = bytes[4 + len] var expected = xorChecksum(bytes.slice(0, 4 + len)) if (checksum !== expected) return null return { type: type, payload: payload, seq: payload.length > 0 ? payload[payload.length - 1] : 0 } } function uint32ToBytes(value) { return [ (value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF ] } function bytesToUint32(bytes, offset) { return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3] } function hexToBytes(hex) { var bytes = [] for (var i = 0; i < hex.length; i += 2) { bytes.push(parseInt(hex.substr(i, 2), 16)) } return bytes } function bytesToHex(bytes) { var hex = '' for (var i = 0; i < bytes.length; i++) { hex += ('0' + bytes[i].toString(16)).slice(-2) } return hex.toUpperCase() } function findCharUuid(chars, shortUuid) { for (var i = 0; i < chars.length; i++) { if (chars[i].uuid.indexOf(shortUuid) !== -1) { return chars[i].uuid } } return null } function on(event, callback) { if (!_listeners[event]) _listeners[event] = [] _listeners[event].push(callback) } function off(event, callback) { if (!_listeners[event]) return if (callback) { _listeners[event] = _listeners[event].filter(function (cb) { return cb !== callback }) } else { _listeners[event] = [] } } function emit(event, data) { if (!_listeners[event]) return _listeners[event].forEach(function (cb) { try { cb(data) } catch (e) { console.error('ble emit error:', e) } }) } function handleNotification(frame) { switch (frame.type) { case NOTIFY.STATUS_REPORT: emit('status', parseStatusReport(frame.payload)) break case NOTIFY.ACK: var ack = parseAck(frame.payload) emit('ack', ack) if (_pendingAcks[ack.seq]) { if (ack.error_code === 0) { _pendingAcks[ack.seq].resolve(ack) } else { _pendingAcks[ack.seq].reject(ack) } delete _pendingAcks[ack.seq] } break case NOTIFY.TREATMENT_COMPLETE: emit('treatment_complete', parseTreatmentComplete(frame.payload)) break case NOTIFY.EXCEPTION: emit('exception', parseException(frame.payload)) break case NOTIFY.BIND_SUCCESS: emit('bind_result', { success: frame.payload[0] === 0x00 }) break } } function parseStatusReport(payload) { if (payload.length < 14) return null return { mode_state: payload[0], region_mask: payload[1], wavelength: payload[2], brightness: payload[3], remaining_ms: bytesToUint32(payload, 4), error_code: payload[8], command_seq: payload[9], battery: payload[10], temperature: payload[11], bind_status: payload[12], subscription: payload[13] } } function parseAck(payload) { return { seq: payload[0], error_code: payload.length > 1 ? payload[1] : 0, error_msg: DEVICE_ERR[payload.length > 1 ? payload[1] : 0] || 'UNKNOWN' } } function parseTreatmentComplete(payload) { return { session_id: bytesToHex(payload.slice(0, 8)), regions: payload[8], total_duration_ms: bytesToUint32(payload, 9), avg_pd: payload[13] } } function parseException(payload) { return { error_code: payload[0], error_msg: DEVICE_ERR[payload[0]] || 'UNKNOWN', temperature: payload.length > 1 ? payload[1] : 0 } } function isConnected() { return _connected && _deviceId !== null } function getDeviceId() { return _deviceId } function startScan(callbacks) { wx.openBluetoothAdapter({ success: function () { wx.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false, success: function () { wx.offBluetoothDeviceFound() wx.onBluetoothDeviceFound(function (res) { var devices = res.devices || [] for (var i = 0; i < devices.length; i++) { var d = devices[i] var name = (d.name || '').toUpperCase() var localName = (d.localName || '').toUpperCase() if (name.indexOf('HOX') !== -1 || localName.indexOf('HOX') !== -1 || name.indexOf('LIGHTMASK') !== -1 || localName.indexOf('LIGHTMASK') !== -1) { wx.stopBluetoothDevicesDiscovery({}) if (callbacks.onFound) callbacks.onFound(d) connect(d.deviceId, callbacks) return } } }) }, fail: function () { if (callbacks.onError) callbacks.onError({ msg: '扫描失败' }) } }) }, fail: function () { if (callbacks.onError) callbacks.onError({ msg: '请开启蓝牙' }) } }) } function connect(deviceId, callbacks) { _deviceId = deviceId _chars = {} wx.createBLEConnection({ deviceId: deviceId, timeout: 10000, success: function () { _connected = true discoverServices(deviceId, callbacks) }, fail: function () { _connected = false if (callbacks.onError) callbacks.onError({ msg: '连接失败' }) } }) } function discoverServices(deviceId, callbacks) { wx.getBLEDeviceServices({ deviceId: deviceId, success: function (res) { var services = res.services var serviceMap = {} for (var i = 0; i < services.length; i++) { var uuid = services[i].uuid.toUpperCase() if (uuid.indexOf(SERVICE.DEVICE_INFO) !== -1) { serviceMap.deviceInfo = services[i].uuid } else if (uuid.indexOf(SERVICE.DATA_COMM) !== -1) { serviceMap.dataComm = services[i].uuid } else if (uuid.indexOf(SERVICE.OTA) !== -1) { serviceMap.ota = services[i].uuid } } var tasks = [] if (serviceMap.deviceInfo) { tasks.push(discoverChars(deviceId, serviceMap.deviceInfo, 'deviceInfo')) } if (serviceMap.dataComm) { tasks.push(discoverChars(deviceId, serviceMap.dataComm, 'dataComm')) } Promise.all(tasks).then(function () { subscribeToNotifications(deviceId, serviceMap.dataComm).then(function () { if (callbacks.onConnected) callbacks.onConnected({ deviceId: deviceId }) }) }) }, fail: function () { if (callbacks.onError) callbacks.onError({ msg: '服务发现失败' }) } }) } function discoverChars(deviceId, serviceId, group) { return new Promise(function (resolve) { wx.getBLEDeviceCharacteristics({ deviceId: deviceId, serviceId: serviceId, success: function (res) { var chars = res.characteristics || [] for (var i = 0; i < chars.length; i++) { var c = chars[i] var uuid = c.uuid.toUpperCase() if (uuid.indexOf(CHAR.DEVICE_INFO) !== -1) _chars.deviceInfo = { uuid: c.uuid, serviceId: serviceId } if (uuid.indexOf(CHAR.COMMAND) !== -1) _chars.command = { uuid: c.uuid, serviceId: serviceId } if (uuid.indexOf(CHAR.STATUS) !== -1) _chars.status = { uuid: c.uuid, serviceId: serviceId } if (uuid.indexOf(CHAR.BOND_INFO) !== -1) _chars.bondInfo = { uuid: c.uuid, serviceId: serviceId } if (uuid.indexOf(CHAR.OTA_CONTROL) !== -1) _chars.otaControl = { uuid: c.uuid, serviceId: serviceId } if (uuid.indexOf(CHAR.OTA_DATA) !== -1) _chars.otaData = { uuid: c.uuid, serviceId: serviceId } if (uuid.indexOf(CHAR.OTA_STATUS) !== -1) _chars.otaStatus = { uuid: c.uuid, serviceId: serviceId } } resolve() }, fail: function () { resolve() } }) }) } function subscribeToNotifications(deviceId, serviceId) { return new Promise(function (resolve) { if (!_chars.status) { resolve(); return } wx.notifyBLECharacteristicValueChange({ deviceId: deviceId, serviceId: _chars.status.serviceId, characteristicId: _chars.status.uuid, state: true, success: function () { wx.onBLECharacteristicValueChange(function (res) { var frame = parseFrame(res.value) if (frame) handleNotification(frame) }) resolve() }, fail: function () { resolve() } }) }) } function writeCommand(type, payload) { return new Promise(function (resolve, reject) { if (!_connected || !_deviceId) { reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' }) return } if (!_chars.command) { reject({ error_code: 0xFF, error_msg: 'command characteristic not found' }) return } var seq = nextSeq() var payloadWithSeq = (payload || []).concat([seq]) var buffer = buildFrame(type, payloadWithSeq) _pendingAcks[seq] = { resolve: resolve, reject: reject } setTimeout(function () { if (_pendingAcks[seq]) { _pendingAcks[seq].reject({ error_code: 0xFF, error_msg: 'ACK timeout' }) delete _pendingAcks[seq] } }, 5000) wx.writeBLECharacteristicValue({ deviceId: _deviceId, serviceId: _chars.command.serviceId, characteristicId: _chars.command.uuid, value: buffer, success: function () {}, fail: function () { delete _pendingAcks[seq] reject({ error_code: 0x0C, error_msg: 'ERR_BLE_DISCONNECTED' }) } }) }) } function readDeviceInfo() { return new Promise(function (resolve, reject) { if (!_connected || !_deviceId || !_chars.deviceInfo) { reject({ msg: '设备未连接或特征值未就绪' }) return } wx.readBLECharacteristicValue({ deviceId: _deviceId, serviceId: _chars.deviceInfo.serviceId, characteristicId: _chars.deviceInfo.uuid, success: function () {}, fail: function () { reject({ msg: '读取设备信息失败' }) } }) var handler = function (res) { if (res.characteristicId.toUpperCase().indexOf(CHAR.DEVICE_INFO) !== -1) { wx.offBLECharacteristicValueChange(handler) var bytes = bufferToBytes(res.value) if (bytes.length >= 14) { resolve({ hw_version: (bytes[0] << 8) | bytes[1], fw_version: (bytes[2] << 8) | bytes[3], device_type: (bytes[4] << 8) | bytes[5], device_id: bytesToHex(bytes.slice(6, 14)) }) } else { reject({ msg: '设备信息格式错误' }) } } } wx.onBLECharacteristicValueChange(handler) }) } function setParams(options) { var regionMask = options.region_mask || REGION.FULL_FACE var wavelength = options.wavelength || WAVELENGTH.R var brightness = options.brightness || 200 var durationMs = options.duration_ms || 600000 var mode = options.mode !== undefined ? options.mode : TREAT_MODE.NORMAL var payload = [ regionMask, wavelength, brightness, uint32ToBytes(durationMs), mode ].reduce(function (a, b) { return a.concat(Array.isArray(b) ? b : [b]) }, []) return writeCommand(CMD.SET_PARAMS, payload) } function startTreatment(regionMask) { var mask = regionMask || REGION.FULL_FACE return writeCommand(CMD.START, [mask]) } function stopTreatment() { return writeCommand(CMD.STOP, []) } function queryStatus() { return writeCommand(CMD.QUERY_STATUS, []) } function bindDevice(userId, bindToken) { var userBytes = hexToBytes(userId) var tokenBytes = hexToBytes(bindToken) var ts = Math.floor(Date.now() / 1000) var tsBytes = uint32ToBytes(ts) var payload = [0x01].concat(userBytes).concat(tokenBytes).concat(tsBytes) return writeCommand(CMD.BIND, payload) } function unbindDevice(userId) { var userBytes = hexToBytes(userId) var payload = [0x02].concat(userBytes) return writeCommand(CMD.UNBIND, payload) } function stopScan() { wx.stopBluetoothDevicesDiscovery({}) wx.offBluetoothDeviceFound() } function disconnect() { if (_deviceId) { wx.closeBLEConnection({ deviceId: _deviceId }) _deviceId = null } _connected = false _chars = {} _pendingAcks = {} _listeners = {} wx.closeBluetoothAdapter({}) } wx.onBLEConnectionStateChange(function (res) { if (!res.connected) { _connected = false emit('disconnected', { deviceId: res.deviceId }) } }) function getRegionName(mask) { var names = [] var bits = [ [0x01, '左脸颊'], [0x02, '右脸颊'], [0x04, '额头'], [0x08, '下巴'], [0x10, '鼻部'], [0x20, '左眼周'], [0x40, '右眼周'] ] for (var i = 0; i < bits.length; i++) { if (mask & bits[i][0]) names.push(bits[i][1]) } return names } function getWavelengthName(code) { var map = { 1: '红外 850nm', 2: '红光 630nm', 3: '紫光 405nm', 4: '黄光 590nm' } return map[code] || '未知' } function getModeStateName(code) { var map = { 0x00: '空闲', 0x01: '扫描中', 0x02: '护理中', 0x03: '已暂停', 0x04: '已完成', 0x05: '异常', 0x06: 'OTA升级中' } return map[code] || '未知' } module.exports = { SERVICE: SERVICE, CHAR: CHAR, CMD: CMD, NOTIFY: NOTIFY, MODE_STATE: MODE_STATE, WAVELENGTH: WAVELENGTH, TREAT_MODE: TREAT_MODE, REGION: REGION, DEVICE_ERR: DEVICE_ERR, isConnected: isConnected, getDeviceId: getDeviceId, startScan: startScan, stopScan: stopScan, connect: connect, disconnect: disconnect, on: on, off: off, readDeviceInfo: readDeviceInfo, setParams: setParams, startTreatment: startTreatment, stopTreatment: stopTreatment, queryStatus: queryStatus, bindDevice: bindDevice, unbindDevice: unbindDevice, buildFrame: buildFrame, parseFrame: parseFrame, parseStatusReport: parseStatusReport, parseAck: parseAck, parseTreatmentComplete: parseTreatmentComplete, parseException: parseException, getRegionName: getRegionName, getWavelengthName: getWavelengthName, getModeStateName: getModeStateName, bufferToBytes: bufferToBytes, bytesToBuffer: bytesToBuffer, hexToBytes: hexToBytes, bytesToHex: bytesToHex }