- 新协议《协议简述-添加扫描时光色》落地: FFE4 Byte17 高3位=扫描光色(1红/2红外/3紫外/4暖黄), 低5位保留贴合状态; Byte18=0xFF 故障; auto-scan 按光色分桶采集(旧固件无标识归 red 桶降级) - diagnosis.js 重写: PD极性(值大=吸收多,固件已确认) NR=本区/本色5区平均-1; 每区4色argmax>0.10判定; 3路PD区几何平均+MAD剔奇异(3σ对n=3失效,审计实证); 严重度0.05/0.15/0.30仅展示; 全参数走 diagnosis_config 热调(极性/阈值/PD映射/亮度) - buildVendorCommand 支持 plan 多区多色单命令下发(每IO独立 红/红外/紫外/暖黄); builder层安全硬上限 治疗亮度≤204(80%) 时长≤600s - 区域映射修正: 掩码=IO纵列 左0x01/中上0x02/中0x04/中下0x08/右0x10, 修复 treatment-setup/manual/treating/getRegionName/i18n 共7处标签错位, FULL_FACE 0x7F→0x1F - 扫描报告页: 新增分区面罩示意图(5区按推荐光着色+模式名+严重度,点击跳光疗介绍), 推荐护理改一条命令多色下发(亮度204); 扫描中设备故障即时中止 - schema.sql diagnosis_config 种子更新(新结构); i18n 清理死键,中英对齐25/24键
429 行
12 KiB
JavaScript
429 行
12 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
|
|
}
|
|
|
|
// 区域 = IO 纵列布局(果果 2026-07 定案):左=IO1(0x01),中上=IO2(0x02),中=IO3(0x04),
|
|
// 中下=IO4(0x08),右=IO5(0x10)。旧命名(左脸颊/额头/鼻部…)来自过时文档,与实物不符,已废弃。
|
|
var REGION = {
|
|
LEFT: 0x01,
|
|
TOP: 0x02,
|
|
MIDDLE: 0x04,
|
|
BOTTOM: 0x08,
|
|
RIGHT: 0x10,
|
|
FULL_FACE: 0x1F
|
|
}
|
|
|
|
var REGION_NAMES = ['left', 'top', 'middle', 'bottom', 'right']
|
|
|
|
// 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
|
|
}
|
|
var ioMasks = [REGION.LEFT, REGION.TOP, REGION.MIDDLE, REGION.BOTTOM, REGION.RIGHT] // IO1..IO5
|
|
var bytes = []
|
|
var i, j
|
|
|
|
if (options.plan && options.plan.length) {
|
|
for (i = 0; i < ioMasks.length; i++) {
|
|
var slot = [0, 0, 0, 0]
|
|
for (j = 0; j < options.plan.length; j++) {
|
|
var p = options.plan[j]
|
|
if ((p.mask & ioMasks[i]) === 0) continue
|
|
var off = WAVE_BYTE_OFFSET[p.wave]
|
|
if (off === undefined) continue
|
|
slot[off] = clampTreat(p.brightness === undefined ? 200 : p.brightness)
|
|
}
|
|
bytes.push(slot[0], slot[1], slot[2], slot[3], (gain >> 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
|
|
}
|