feat(diagnosis): 按新协议实现多光谱诊断算法+分区差异化下发+报告面罩图

- 新协议《协议简述-添加扫描时光色》落地: 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键
这个提交包含在:
Guoguo
2026-07-28 04:36:51 -07:00
父节点 b5416f56af
当前提交 6b91ebe35d
修改 19 个文件,包含 505 行新增234 行删除
+1 -1
查看文件
@@ -99,7 +99,7 @@ function handleValueChange(res) {
console.warn('[BLE] ADC checksum mismatch, dropping')
return
}
emit('adc', { pd: parsed.pd, vbat: vbatMv, battery: batteryPct, fitting: parsed.fitting, run_state: parsed.run_state })
emit('adc', { pd: parsed.pd, vbat: vbatMv, battery: batteryPct, fitting: parsed.fitting, scan_wave: parsed.scan_wave, run_state: parsed.run_state, is_fault: parsed.is_fault })
emit('battery', { battery: batteryPct, vbat: vbatMv })
emit('fitting', { fitting: parsed.fitting })
emit('run_state', { run_state: parsed.run_state })
+74 -34
查看文件
@@ -57,18 +57,23 @@ var TREAT_MODE = {
SMART: 1
}
// 区域 = IO 纵列布局(果果 2026-07 定案):左=IO1(0x01),中上=IO2(0x02),中=IO3(0x04),
// 中下=IO4(0x08),右=IO5(0x10)。旧命名(左脸颊/额头/鼻部…)来自过时文档,与实物不符,已废弃。
var REGION = {
LEFT_CHEEK: 0x01,
RIGHT_CHEEK: 0x02,
FOREHEAD: 0x04,
CHIN: 0x08,
NOSE: 0x10,
LEFT_EYE: 0x20,
RIGHT_EYE: 0x40,
FULL_FACE: 0x7F
LEFT: 0x01,
TOP: 0x02,
MIDDLE: 0x04,
BOTTOM: 0x08,
RIGHT: 0x10,
FULL_FACE: 0x1F
}
var REGION_NAMES = ['left_cheek', 'right_cheek', 'forehead', 'chin', 'nose', 'left_eye', 'right_eye']
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',
@@ -167,37 +172,64 @@ var CONTROL = {
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 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
]
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
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
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(red, infrared, uv, warmYellow, (gain >> 8) & 0xFF, gain & 0xFF)
}
bytes.push((holdSeconds >> 8) & 0xFF, holdSeconds & 0xFF)
@@ -217,13 +249,18 @@ function parseVendorStatus(buffer) {
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],
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)
}
@@ -326,11 +363,13 @@ function parseException(payload) {
// --- display helpers ---
// 掩码 = IO 纵列布局(与 REGION 常量一致)。注意:此处为硬编码中文(历史遗留,
// history/treatment-done 在用);新页面请改用 i18n common.regions.* 取标签。
function getRegionName(mask) {
var names = []
var bits = [
[0x01, '区'], [0x02, '区'], [0x04, '区'],
[0x08, '中区'], [0x10, '下区'], [0x20, '左上区'], [0x40, '右上区']
[0x01, '区'], [0x02, '中上区'], [0x04, '区'],
[0x08, '中区'], [0x10, '区']
]
for (var i = 0; i < bits.length; i++) {
if (mask & bits[i][0]) names.push(bits[i][1])
@@ -362,6 +401,7 @@ module.exports = {
TREAT_MODE: TREAT_MODE,
REGION: REGION,
REGION_NAMES: REGION_NAMES,
SCAN_WAVE_KEY: SCAN_WAVE_KEY,
CONTROL: CONTROL,
DEVICE_ERR: DEVICE_ERR,