/** * 协议编解码 * 帧格式: [0x24 HEAD][FuncCode][...payload][0x23 TAIL] * * 注意: 本协议无长度字段和 CRC,使用帧头 0x24 / 帧尾 0x23 作为边界 * 接收缓冲区按字节扫描,找到完整帧后解析 */ import { FRAME_HEAD, FRAME_TAIL, CMD, REPORT, BLE_ERROR } from './constants.js' // ========== 基础工具 ========== /** Uint8Array -> ArrayBuffer */ export function bufferToArrayBuffer(u8) { return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) } /** ArrayBuffer -> Uint8Array */ export function arrayBufferToU8(ab) { return new Uint8Array(ab) } /** Uint8Array -> 16 进制字符串(大写, 空格分隔) */ export function bytesToHex(u8) { return Array.from(u8) .map(b => b.toString(16).padStart(2, '0').toUpperCase()) .join(' ') } /** 16 进制字符串 -> Uint8Array */ export function hexToBytes(hex) { const s = hex.replace(/\s|0x|,/g, '') const arr = [] for (let i = 0; i < s.length; i += 2) arr.push(parseInt(s.substr(i, 2), 16)) return new Uint8Array(arr) } // ========== 16-bit 整型 <-> 字节 (大端 big-endian) ========== /** 有符号 int16 (范围 -32768 ~ 32767) -> [high, low] */ export function int16ToBytes(v) { let n = Math.round(Number(v)) if (!Number.isFinite(n)) n = 0 if (n < -0x8000 || n > 0x7FFF) { throw new RangeError(`int16 out of range: ${v}`) } if (n < 0) n += 0x10000 // 负数转补码 return [(n >> 8) & 0xFF, n & 0xFF] } /** [high, low] -> 有符号 int16 */ export function int16FromBytes(hi, lo) { const n = ((hi & 0xFF) << 8) | (lo & 0xFF) return n >= 0x8000 ? n - 0x10000 : n } /** 无符号 uint16 (范围 0 ~ 65535) -> [high, low] */ export function uint16ToBytes(v) { let n = Math.round(Number(v)) if (!Number.isFinite(n)) n = 0 if (n < 0 || n > 0xFFFF) { throw new RangeError(`uint16 out of range: ${v}`) } return [(n >> 8) & 0xFF, n & 0xFF] } /** [high, low] -> 无符号 uint16 */ export function uint16FromBytes(hi, lo) { return ((hi & 0xFF) << 8) | (lo & 0xFF) } // ========== 统一打包 ========== /** 用给定功能码和 payload 组装完整帧 */ export function buildFrame(funcCode, payload = []) { const data = [FRAME_HEAD, funcCode, ...payload, FRAME_TAIL] return new Uint8Array(data) } // ========== 编码: APP -> 设备 ========== /** * 0x01 基本功能 * 帧格式 (共 12 字节): * [0x24][0x01][power][moxiState][preheatState][mute][mode][subMode][temperature][angle][duration][0x23] * @param {Object} opt * @param {number} opt.power 电源 0x00/0x01 * @param {number} opt.moxiState 艾灸 0x00/0x01/0x02 * @param {number} opt.preheatState 预热 0x00/0x01/0x02 * @param {number} opt.mute 静音 * @param {number} opt.mode 模式 * @param {number} opt.subMode 子模式 * @param {number} opt.temperature 温度 * @param {number} opt.angle 座椅角度 * @param {number} opt.duration 艾炙时长(分钟) */ export function encodeBasic(opt) { const { power = 0x00, moxiState = 0x00, preheatState = 0x00, mute = 0x00, mode = 0x01, subMode = 0x01, temperature = 0x01, angle = 0x00, duration = 0x00 } = opt || {} return buildFrame(CMD.BASIC, [ power, moxiState, preheatState, mute, mode, subMode, temperature, angle, duration ]) } /** * 0x02 模式参数设置1: 步骤 1-7 (穴位编号 + 时长) * @param {Object} opt * @param {number} opt.mode * @param {number} opt.subMode * @param {number} opt.temperature * @param {Array<{point:number,duration:number}>} opt.steps 步骤列表, 长度最多 7, 不足补 0 */ export function encodeModeParam1({ mode, subMode, temperature, steps = [] }) { return _encodeSteps(CMD.MODE_PARAM_1, [mode, subMode, temperature], steps, 7) } /** * 0x03 模式参数设置2: 步骤 8-14 * @param {Object} opt * @param {number} opt.mode * @param {number} opt.subMode * @param {Array<{point:number,duration:number}>} opt.steps 步骤列表, 长度最多 7, 不足补 0 * @param {number} [opt.reserved=0x00] */ export function encodeModeParam2({ mode, subMode, steps = [], reserved = 0x00 }) { return _encodeSteps(CMD.MODE_PARAM_2, [mode, subMode, reserved], steps, 7) } function _encodeSteps(funcCode, prefix, steps, count) { const payload = [...prefix] for (let i = 0; i < count; i++) { const s = steps[i] || { point: 0, duration: 0 } payload.push(s.point & 0xFF, s.duration & 0xFF) } return buildFrame(funcCode, payload) } /** * 0x05 穴位坐标下发: 一次下发 2 个穴位 * 坐标采用大端有符号 int16 (支持负值, 范围 -32768 ~ 32767) * @param {Array<{point:number,x:number,y:number}>} points 最多 2 个穴位 */ export function encodeAcupoints(points = []) { const payload = [] for (let i = 0; i < 2; i++) { const p = points[i] || { point: 0, x: 0, y: 0 } const [xh, xl] = int16ToBytes(p.x) const [yh, yl] = int16ToBytes(p.y) payload.push(p.point & 0xFF, xh, xl, yh, yl) } return buildFrame(CMD.MODE_PARAM_5, payload) } // ========== 解码: 设备 -> APP ========== /** 解析参数组1 (功能码 0x01) */ export function decodeReportGroup1(payload) { // payload 已剥离 head/tail/funcCode, 期望 14 字节 if (payload.length < 14) throw new Error(BLE_ERROR.FRAME_INVALID) return { type: 'GROUP_1', reserved: payload[0], power: payload[1], runtimeState: payload[2], mute: payload[3], mode: payload[4], subMode: payload[5], temperature: payload[6], durationSet: payload[7], // 设置时间(分钟) remainMinute: payload[8], remainSecond: payload[9], consumable: payload[10], // 耗材剩余个数 filterPercent: payload[11], // 滤芯已使用 % recycleBinPct: payload[12], // 回收仓占用 % foreignDetect: payload[13] // 异物检测 01正常/02异常 } } /** 解析参数组2 (功能码 0x02) */ export function decodeReportGroup2(payload) { // 期望 11 字节 if (payload.length < 11) throw new Error(BLE_ERROR.FRAME_INVALID) const tempHigh = payload[8] const tempLow = payload[9] // 温度: 高 8 位 + 低 8 位, 无符号大端. csv 示例 0x01 0x68 = 360 -> 36.0℃ const temperatureRaw = uint16FromBytes(tempHigh, tempLow) return { type: 'GROUP_2', preheatState: payload[0], // 含自动上艾/点火状态 preheatPercent: payload[1], ignitePercent: payload[2], bodyDetect: payload[3], machineForeign: payload[4], chairAngle: payload[5], currentStep: payload[6], currentPoint: payload[7], moxiHeadTempRaw: temperatureRaw, moxiHeadTempC: temperatureRaw / 10, // 摄氏度 firmwareVersion: payload[10] } } /** * 解析一个完整帧(已确认 head/tail) * @param {Uint8Array} frame 含 head/tail 的完整帧 * @returns {Object} { funcCode, payload, parsed } */ export function decodeFrame(frame) { if (!frame || frame.length < 3) throw new Error(BLE_ERROR.FRAME_INVALID) if (frame[0] !== FRAME_HEAD || frame[frame.length - 1] !== FRAME_TAIL) { throw new Error(BLE_ERROR.FRAME_INVALID) } const funcCode = frame[1] const payload = frame.slice(2, frame.length - 1) let parsed = null try { if (funcCode === REPORT.GROUP_1) parsed = decodeReportGroup1(payload) else if (funcCode === REPORT.GROUP_2) parsed = decodeReportGroup2(payload) } catch (_) { /* payload 结构不对,保留 raw */ } return { funcCode, payload, parsed } } // ========== 流式解析器(应对粘包/分包) ========== export class FrameParser { constructor(onFrame, onError) { this._buf = [] this._onFrame = onFrame || (() => {}) this._onError = onError || (() => {}) } /** 追加一段接收到的字节 */ feed(u8) { for (let i = 0; i < u8.length; i++) this._buf.push(u8[i]) this._scan() } reset() { this._buf = [] } _scan() { while (true) { // 找 HEAD const headIdx = this._buf.indexOf(FRAME_HEAD) if (headIdx < 0) { this._buf = []; return } if (headIdx > 0) this._buf.splice(0, headIdx) // 丢弃 head 前的垃圾 // 找 TAIL const tailIdx = this._buf.indexOf(FRAME_TAIL, 1) if (tailIdx < 0) return // 数据未到齐,等待下次 feed const frame = new Uint8Array(this._buf.slice(0, tailIdx + 1)) this._buf.splice(0, tailIdx + 1) try { this._onFrame(decodeFrame(frame), frame) } catch (e) { this._onError(e, frame) } } } }