// 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 } // 区域 = IO↔物理位置(果果 2026-07-28 真机实测):右=IO1(0x01) 左=IO2(0x02) 上=IO3(0x04) // 中=IO4(0x08) 下=IO5(0x10)。此前口头给的"纵列左IO1/中上IO2…"实测不符,以本表为准。 var REGION = { RIGHT: 0x01, LEFT: 0x02, TOP: 0x04, MIDDLE: 0x08, BOTTOM: 0x10, FULL_FACE: 0x1F } var REGION_NAMES = ['right', 'left', 'top', 'middle', 'bottom'] // FFE4 Byte17 高三位 = 扫描时光色指示(协议简述-添加扫描时光色.docx 2026-07): // 001=红光 010=红外 011=紫外(蓝) 100=暖黄。注意与下发命令的 WAVELENGTH 编码 // (IR=1,R=2,UV=3,Y=4) 不同——1/2 正好是红与红外互换,绝不能复用那套常量。 var SCAN_WAVE_KEY = { 1: 'red', 2: 'ir', 3: 'uv', 4: 'yellow' } 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 34-byte command protocol --- // Byte1-30: IO1~IO5 (6B each: R, IR, UV, Y, GainH, GainL) // Byte31-32: hold time (seconds, big-endian) // Byte33: control (0=IDLE, 1=scan, 2=treat) // Byte34: XOR checksum of Byte1-33 var CONTROL = { IDLE: 0x00, SCAN: 0x01, TREAT: 0x02 } // 每 IO 6 字节,颜色格顺序 [红][红外][紫外][暖黄](厂商原表,新版协议简述再次确认)。 // 两种调用方式: // 单色广播(旧):{ region_mask, wavelength(编码), brightness, ... } // 分区差异化(新):{ plan: [{ mask, wave:'red'|'ir'|'uv'|'yellow', brightness }], hold_time, control } // —— 同一条命令里不同区可用不同颜色不同亮度(协议本身就支持 5 IO × 4 色独立)。 var WAVE_BYTE_OFFSET = { red: 0, ir: 1, uv: 2, yellow: 3 } // 安全包线(builder 层硬上限,纵深防御——即使服务端 diagnosis_config 误配也不可突破): // 治疗亮度 ≤204(80%),单次时长 ≤600s(10min)。固件侧安全机制大多未实现,此处是最后防线。 var MAX_TREAT_BRIGHTNESS = 204 var MAX_HOLD_SECONDS = 600 function buildVendorCommand(options) { options = options || {} var gain = clampUInt16(options.current_gain === undefined ? 0 : options.current_gain) var durationMs = options.duration_ms || 600000 var holdSeconds = clampUInt16(options.hold_time === undefined ? Math.round(durationMs / 1000) : options.hold_time) var control = options.control !== undefined ? options.control : CONTROL.TREAT if (holdSeconds > MAX_HOLD_SECONDS) holdSeconds = MAX_HOLD_SECONDS var isTreat = control === CONTROL.TREAT function clampTreat(b) { b = clampByte(b) return isTreat && b > MAX_TREAT_BRIGHTNESS ? MAX_TREAT_BRIGHTNESS : b } // IO1..IO5 物理槽位 = bit 升序(Byte n*6 对应 mask 1<> 8) & 0xFF, gain & 0xFF) } } else { var regionMask = options.region_mask || REGION.FULL_FACE var wavelength = options.wavelength || WAVELENGTH.R var brightness = clampTreat(options.brightness === undefined ? 200 : options.brightness) for (i = 0; i < ioMasks.length; i++) { var enabled = (regionMask & ioMasks[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(control & 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)) } // Byte17 复用:低位=贴合状态(0/1),高三位=扫描时光色指示(0 表示无标识/非扫描态或旧固件)。 // Byte18 运行状态:0=IDLE 1=检测 2=治疗 0xFF=故障。 var scanWaveCode = (bytes[16] >> 5) & 0x07 return { type: 'adc', is_heartbeat: false, pd: pds19, vbat: bytes[14] | (bytes[15] << 8), fitting: bytes[16] & 0x1F, scan_wave: SCAN_WAVE_KEY[scanWaveCode] || null, run_state: bytes[17], is_fault: bytes[17] === 0xFF, checksum_ok: checksum19 === bytes[18], raw_hex: bytesToHex(bytes) } } if (bytes.length === 34) { var ios34 = [] for (var j = 0; j < 5; j++) { var off34 = j * 6 ios34.push({ red: bytes[off34], infrared: bytes[off34 + 1], uv: bytes[off34 + 2], warm_yellow: bytes[off34 + 3], gain: (bytes[off34 + 4] << 8) | bytes[off34 + 5] }) } var expected34 = xorChecksum(bytes.slice(0, 33)) return { type: 'params', is_heartbeat: false, ios: ios34, hold_time: (bytes[30] << 8) | bytes[31], control: bytes[32], checksum: bytes[33], checksum_ok: expected34 === bytes[33], 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 --- // 掩码 = IO 纵列布局(与 REGION 常量一致)。注意:此处为硬编码中文(历史遗留, // history/treatment-done 在用);新页面请改用 i18n common.regions.* 取标签。 function getRegionName(mask) { var names = [] var bits = [ [0x01, '右区'], [0x02, '左区'], [0x04, '上区'], [0x08, '中区'], [0x10, '下区'] ] 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, SCAN_WAVE_KEY: SCAN_WAVE_KEY, CONTROL: CONTROL, DEVICE_ERR: DEVICE_ERR, bufferToBytes: bufferToBytes, bytesToBuffer: bytesToBuffer, xorChecksum: xorChecksum, uint32ToBytes: uint32ToBytes, bytesToUint32: bytesToUint32, hexToBytes: hexToBytes, bytesToHex: bytesToHex, buildVendorCommand: buildVendorCommand, buildFrame: buildFrame, parseFrame: parseFrame, parseVendorStatus: parseVendorStatus, parseStatusReport: parseStatusReport, parseAck: parseAck, parseTreatmentComplete: parseTreatmentComplete, parseException: parseException, getRegionName: getRegionName, getWavelengthName: getWavelengthName, getModeStateName: getModeStateName }