// BLE protocol: frame encoding/decoding, checksum, constants var PROTOCOL_MODE = 'vendor_33' var SERVICE = { DEVICE_INFO: 'FFE0', DATA_COMM: 'FFE1', OTA: 'FFE2' } var CHAR = { DEVICE_INFO: 'FFE3', COMMAND: 'FFE1', STATUS: 'FFE4', 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' } // --- byte utilities --- 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 clampByte(value) { var n = parseInt(value, 10) if (isNaN(n)) n = 0 return Math.max(0, Math.min(255, n)) } function clampUInt16(value) { var n = parseInt(value, 10) if (isNaN(n)) n = 0 return Math.max(0, Math.min(65535, n)) } 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() } // --- vendor 33-byte command protocol --- function buildVendorCommand33(options) { options = options || {} var regionMask = options.region_mask || REGION.FULL_FACE var wavelength = options.wavelength || WAVELENGTH.R var brightness = clampByte(options.brightness === undefined ? 200 : options.brightness) var gain = clampUInt16(options.current_gain === undefined ? brightness : options.current_gain) var durationMs = options.duration_ms || 600000 var holdSeconds = clampUInt16(options.hold_time === undefined ? Math.round(durationMs / 1000) : options.hold_time) var regions = [ REGION.LEFT_CHEEK, REGION.RIGHT_CHEEK, REGION.FOREHEAD, REGION.CHIN, REGION.NOSE ] var bytes = [] for (var i = 0; i < regions.length; i++) { var enabled = (regionMask & regions[i]) !== 0 var red = 0 var infrared = 0 var uv = 0 var warmYellow = 0 if (enabled) { if (wavelength === WAVELENGTH.R) red = brightness if (wavelength === WAVELENGTH.IR) infrared = brightness if (wavelength === WAVELENGTH.UV) uv = brightness if (wavelength === WAVELENGTH.Y) warmYellow = brightness } bytes.push(red, infrared, uv, warmYellow, (gain >> 8) & 0xFF, gain & 0xFF) } bytes.push((holdSeconds >> 8) & 0xFF, holdSeconds & 0xFF) bytes.push(xorChecksum(bytes)) return bytes } function parseVendorStatus(buffer) { var bytes = bufferToBytes(buffer) if (bytes.length === 1) { return { type: 'heartbeat', heartbeat: bytes[0], is_heartbeat: true, raw_hex: bytesToHex(bytes) } } if (bytes.length === 19) { var checksum19 = xorChecksum(bytes.slice(0, 18)) var pds19 = [] for (var p = 0; p < 7; p++) { pds19.push(bytes[p * 2] | (bytes[p * 2 + 1] << 8)) } return { type: 'adc', is_heartbeat: false, pd: pds19, vbat: bytes[14] | (bytes[15] << 8), fitting: bytes[16], run_state: bytes[17], checksum_ok: checksum19 === bytes[18], raw_hex: bytesToHex(bytes) } } if (bytes.length === 17) { var checksum = xorChecksum(bytes.slice(0, 16)) var pds = [] for (var p = 0; p < 7; p++) { pds.push(bytes[p * 2] | (bytes[p * 2 + 1] << 8)) } return { type: 'adc', is_heartbeat: false, pd: pds, vbat: bytes[14] | (bytes[15] << 8), fitting: -1, run_state: -1, checksum_ok: checksum === bytes[16], raw_hex: bytesToHex(bytes) } } if (bytes.length === 33) { var ios = [] for (var i = 0; i < 5; i++) { var off = i * 6 ios.push({ red: bytes[off], infrared: bytes[off + 1], uv: bytes[off + 2], warm_yellow: bytes[off + 3], gain: (bytes[off + 4] << 8) | bytes[off + 5] }) } return { type: 'params', is_heartbeat: false, ios: ios, hold_time: (bytes[30] << 8) | bytes[31], checksum: bytes[32], raw_hex: bytesToHex(bytes) } } return { type: 'unknown', is_heartbeat: false, raw_bytes: bytes, raw_hex: bytesToHex(bytes) } } // --- frame encoding/decoding --- 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 } } // --- notification parsing --- 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 } } // --- display helpers --- 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 = { PROTOCOL_MODE: PROTOCOL_MODE, SERVICE: SERVICE, CHAR: CHAR, CMD: CMD, NOTIFY: NOTIFY, MODE_STATE: MODE_STATE, WAVELENGTH: WAVELENGTH, TREAT_MODE: TREAT_MODE, REGION: REGION, REGION_NAMES: REGION_NAMES, DEVICE_ERR: DEVICE_ERR, bufferToBytes: bufferToBytes, bytesToBuffer: bytesToBuffer, xorChecksum: xorChecksum, uint32ToBytes: uint32ToBytes, bytesToUint32: bytesToUint32, hexToBytes: hexToBytes, bytesToHex: bytesToHex, buildVendorCommand33: buildVendorCommand33, buildFrame: buildFrame, parseFrame: parseFrame, parseVendorStatus: parseVendorStatus, parseStatusReport: parseStatusReport, parseAck: parseAck, parseTreatmentComplete: parseTreatmentComplete, parseException: parseException, getRegionName: getRegionName, getWavelengthName: getWavelengthName, getModeStateName: getModeStateName }