| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261 |
- /**
- * 协议编解码
- * 帧格式: [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)
- }
- }
- }
- }
|