文件
jw-beauty/miniprogram/services/ble/protocol.js
T

434 行
10 KiB
JavaScript

// 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 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
}
function buildVendorCommand(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 ? 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
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(control & 0xFF)
bytes.push(xorChecksum(bytes))
return bytes
}
// deprecated: alias kept for backward compat, actually outputs 34 bytes
function buildVendorCommand33(options) {
return buildVendorCommand(options)
}
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 === 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)
}
}
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]
})
}
var expected33 = xorChecksum(bytes.slice(0, 32))
return {
type: 'params',
is_heartbeat: false,
ios: ios,
hold_time: (bytes[30] << 8) | bytes[31],
control: -1,
checksum: bytes[32],
checksum_ok: expected33 === 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,
CONTROL: CONTROL,
DEVICE_ERR: DEVICE_ERR,
bufferToBytes: bufferToBytes,
bytesToBuffer: bytesToBuffer,
xorChecksum: xorChecksum,
uint32ToBytes: uint32ToBytes,
bytesToUint32: bytesToUint32,
hexToBytes: hexToBytes,
bytesToHex: bytesToHex,
buildVendorCommand: buildVendorCommand,
buildVendorCommand33: buildVendorCommand33,
buildFrame: buildFrame,
parseFrame: parseFrame,
parseVendorStatus: parseVendorStatus,
parseStatusReport: parseStatusReport,
parseAck: parseAck,
parseTreatmentComplete: parseTreatmentComplete,
parseException: parseException,
getRegionName: getRegionName,
getWavelengthName: getWavelengthName,
getModeStateName: getModeStateName
}