protocol.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. /**
  2. * 协议编解码
  3. * 帧格式: [0x24 HEAD][FuncCode][...payload][0x23 TAIL]
  4. *
  5. * 注意: 本协议无长度字段和 CRC,使用帧头 0x24 / 帧尾 0x23 作为边界
  6. * 接收缓冲区按字节扫描,找到完整帧后解析
  7. */
  8. import {
  9. FRAME_HEAD, FRAME_TAIL,
  10. CMD, REPORT, BLE_ERROR
  11. } from './constants.js'
  12. // ========== 基础工具 ==========
  13. /** Uint8Array -> ArrayBuffer */
  14. export function bufferToArrayBuffer(u8) {
  15. return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength)
  16. }
  17. /** ArrayBuffer -> Uint8Array */
  18. export function arrayBufferToU8(ab) {
  19. return new Uint8Array(ab)
  20. }
  21. /** Uint8Array -> 16 进制字符串(大写, 空格分隔) */
  22. export function bytesToHex(u8) {
  23. return Array.from(u8)
  24. .map(b => b.toString(16).padStart(2, '0').toUpperCase())
  25. .join(' ')
  26. }
  27. /** 16 进制字符串 -> Uint8Array */
  28. export function hexToBytes(hex) {
  29. const s = hex.replace(/\s|0x|,/g, '')
  30. const arr = []
  31. for (let i = 0; i < s.length; i += 2) arr.push(parseInt(s.substr(i, 2), 16))
  32. return new Uint8Array(arr)
  33. }
  34. // ========== 16-bit 整型 <-> 字节 (大端 big-endian) ==========
  35. /** 有符号 int16 (范围 -32768 ~ 32767) -> [high, low] */
  36. export function int16ToBytes(v) {
  37. let n = Math.round(Number(v))
  38. if (!Number.isFinite(n)) n = 0
  39. if (n < -0x8000 || n > 0x7FFF) {
  40. throw new RangeError(`int16 out of range: ${v}`)
  41. }
  42. if (n < 0) n += 0x10000 // 负数转补码
  43. return [(n >> 8) & 0xFF, n & 0xFF]
  44. }
  45. /** [high, low] -> 有符号 int16 */
  46. export function int16FromBytes(hi, lo) {
  47. const n = ((hi & 0xFF) << 8) | (lo & 0xFF)
  48. return n >= 0x8000 ? n - 0x10000 : n
  49. }
  50. /** 无符号 uint16 (范围 0 ~ 65535) -> [high, low] */
  51. export function uint16ToBytes(v) {
  52. let n = Math.round(Number(v))
  53. if (!Number.isFinite(n)) n = 0
  54. if (n < 0 || n > 0xFFFF) {
  55. throw new RangeError(`uint16 out of range: ${v}`)
  56. }
  57. return [(n >> 8) & 0xFF, n & 0xFF]
  58. }
  59. /** [high, low] -> 无符号 uint16 */
  60. export function uint16FromBytes(hi, lo) {
  61. return ((hi & 0xFF) << 8) | (lo & 0xFF)
  62. }
  63. // ========== 统一打包 ==========
  64. /** 用给定功能码和 payload 组装完整帧 */
  65. export function buildFrame(funcCode, payload = []) {
  66. const data = [FRAME_HEAD, funcCode, ...payload, FRAME_TAIL]
  67. return new Uint8Array(data)
  68. }
  69. // ========== 编码: APP -> 设备 ==========
  70. /**
  71. * 0x01 基本功能
  72. * 帧格式 (共 12 字节):
  73. * [0x24][0x01][power][moxiState][preheatState][mute][mode][subMode][temperature][angle][duration][0x23]
  74. * @param {Object} opt
  75. * @param {number} opt.power 电源 0x00/0x01
  76. * @param {number} opt.moxiState 艾灸 0x00/0x01/0x02
  77. * @param {number} opt.preheatState 预热 0x00/0x01/0x02
  78. * @param {number} opt.mute 静音
  79. * @param {number} opt.mode 模式
  80. * @param {number} opt.subMode 子模式
  81. * @param {number} opt.temperature 温度
  82. * @param {number} opt.angle 座椅角度
  83. * @param {number} opt.duration 艾炙时长(分钟)
  84. */
  85. export function encodeBasic(opt) {
  86. const {
  87. power = 0x00, moxiState = 0x00, preheatState = 0x00,
  88. mute = 0x00, mode = 0x01, subMode = 0x01,
  89. temperature = 0x01, angle = 0x00, duration = 0x00
  90. } = opt || {}
  91. return buildFrame(CMD.BASIC, [
  92. power, moxiState, preheatState, mute,
  93. mode, subMode, temperature, angle, duration
  94. ])
  95. }
  96. /**
  97. * 0x02 模式参数设置1: 步骤 1-7 (穴位编号 + 时长)
  98. * @param {Object} opt
  99. * @param {number} opt.mode
  100. * @param {number} opt.subMode
  101. * @param {number} opt.temperature
  102. * @param {Array<{point:number,duration:number}>} opt.steps 步骤列表, 长度最多 7, 不足补 0
  103. */
  104. export function encodeModeParam1({ mode, subMode, temperature, steps = [] }) {
  105. return _encodeSteps(CMD.MODE_PARAM_1, [mode, subMode, temperature], steps, 7)
  106. }
  107. /**
  108. * 0x03 模式参数设置2: 步骤 8-14
  109. * @param {Object} opt
  110. * @param {number} opt.mode
  111. * @param {number} opt.subMode
  112. * @param {Array<{point:number,duration:number}>} opt.steps 步骤列表, 长度最多 7, 不足补 0
  113. * @param {number} [opt.reserved=0x00]
  114. */
  115. export function encodeModeParam2({ mode, subMode, steps = [], reserved = 0x00 }) {
  116. return _encodeSteps(CMD.MODE_PARAM_2, [mode, subMode, reserved], steps, 7)
  117. }
  118. function _encodeSteps(funcCode, prefix, steps, count) {
  119. const payload = [...prefix]
  120. for (let i = 0; i < count; i++) {
  121. const s = steps[i] || { point: 0, duration: 0 }
  122. payload.push(s.point & 0xFF, s.duration & 0xFF)
  123. }
  124. return buildFrame(funcCode, payload)
  125. }
  126. /**
  127. * 0x05 穴位坐标下发: 一次下发 2 个穴位
  128. * 坐标采用大端有符号 int16 (支持负值, 范围 -32768 ~ 32767)
  129. * @param {Array<{point:number,x:number,y:number}>} points 最多 2 个穴位
  130. */
  131. export function encodeAcupoints(points = []) {
  132. const payload = []
  133. for (let i = 0; i < 2; i++) {
  134. const p = points[i] || { point: 0, x: 0, y: 0 }
  135. const [xh, xl] = int16ToBytes(p.x)
  136. const [yh, yl] = int16ToBytes(p.y)
  137. payload.push(p.point & 0xFF, xh, xl, yh, yl)
  138. }
  139. return buildFrame(CMD.MODE_PARAM_5, payload)
  140. }
  141. // ========== 解码: 设备 -> APP ==========
  142. /** 解析参数组1 (功能码 0x01) */
  143. export function decodeReportGroup1(payload) {
  144. // payload 已剥离 head/tail/funcCode, 期望 14 字节
  145. if (payload.length < 14) throw new Error(BLE_ERROR.FRAME_INVALID)
  146. return {
  147. type: 'GROUP_1',
  148. reserved: payload[0],
  149. power: payload[1],
  150. runtimeState: payload[2],
  151. mute: payload[3],
  152. mode: payload[4],
  153. subMode: payload[5],
  154. temperature: payload[6],
  155. durationSet: payload[7], // 设置时间(分钟)
  156. remainMinute: payload[8],
  157. remainSecond: payload[9],
  158. consumable: payload[10], // 耗材剩余个数
  159. filterPercent: payload[11], // 滤芯已使用 %
  160. recycleBinPct: payload[12], // 回收仓占用 %
  161. foreignDetect: payload[13] // 异物检测 01正常/02异常
  162. }
  163. }
  164. /** 解析参数组2 (功能码 0x02) */
  165. export function decodeReportGroup2(payload) {
  166. // 期望 11 字节
  167. if (payload.length < 11) throw new Error(BLE_ERROR.FRAME_INVALID)
  168. const tempHigh = payload[8]
  169. const tempLow = payload[9]
  170. // 温度: 高 8 位 + 低 8 位, 无符号大端. csv 示例 0x01 0x68 = 360 -> 36.0℃
  171. const temperatureRaw = uint16FromBytes(tempHigh, tempLow)
  172. return {
  173. type: 'GROUP_2',
  174. preheatState: payload[0], // 含自动上艾/点火状态
  175. preheatPercent: payload[1],
  176. ignitePercent: payload[2],
  177. bodyDetect: payload[3],
  178. machineForeign: payload[4],
  179. chairAngle: payload[5],
  180. currentStep: payload[6],
  181. currentPoint: payload[7],
  182. moxiHeadTempRaw: temperatureRaw,
  183. moxiHeadTempC: temperatureRaw / 10, // 摄氏度
  184. firmwareVersion: payload[10]
  185. }
  186. }
  187. /**
  188. * 解析一个完整帧(已确认 head/tail)
  189. * @param {Uint8Array} frame 含 head/tail 的完整帧
  190. * @returns {Object} { funcCode, payload, parsed }
  191. */
  192. export function decodeFrame(frame) {
  193. if (!frame || frame.length < 3) throw new Error(BLE_ERROR.FRAME_INVALID)
  194. if (frame[0] !== FRAME_HEAD || frame[frame.length - 1] !== FRAME_TAIL) {
  195. throw new Error(BLE_ERROR.FRAME_INVALID)
  196. }
  197. const funcCode = frame[1]
  198. const payload = frame.slice(2, frame.length - 1)
  199. let parsed = null
  200. try {
  201. if (funcCode === REPORT.GROUP_1) parsed = decodeReportGroup1(payload)
  202. else if (funcCode === REPORT.GROUP_2) parsed = decodeReportGroup2(payload)
  203. } catch (_) { /* payload 结构不对,保留 raw */ }
  204. return { funcCode, payload, parsed }
  205. }
  206. // ========== 流式解析器(应对粘包/分包) ==========
  207. export class FrameParser {
  208. constructor(onFrame, onError) {
  209. this._buf = []
  210. this._onFrame = onFrame || (() => {})
  211. this._onError = onError || (() => {})
  212. }
  213. /** 追加一段接收到的字节 */
  214. feed(u8) {
  215. for (let i = 0; i < u8.length; i++) this._buf.push(u8[i])
  216. this._scan()
  217. }
  218. reset() { this._buf = [] }
  219. _scan() {
  220. while (true) {
  221. // 找 HEAD
  222. const headIdx = this._buf.indexOf(FRAME_HEAD)
  223. if (headIdx < 0) { this._buf = []; return }
  224. if (headIdx > 0) this._buf.splice(0, headIdx) // 丢弃 head 前的垃圾
  225. // 找 TAIL
  226. const tailIdx = this._buf.indexOf(FRAME_TAIL, 1)
  227. if (tailIdx < 0) return // 数据未到齐,等待下次 feed
  228. const frame = new Uint8Array(this._buf.slice(0, tailIdx + 1))
  229. this._buf.splice(0, tailIdx + 1)
  230. try {
  231. this._onFrame(decodeFrame(frame), frame)
  232. } catch (e) {
  233. this._onError(e, frame)
  234. }
  235. }
  236. }
  237. }