Files
hms/apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts
iven 92ffd8cecb feat(mp): Veepoo M2 BLE 管线扩展 — 精准睡眠数据 + 自动测量 + UI 重构
- 新增 VeepooBridge API:精准睡眠读取(readPreciseSleepData)、B3自动测量配置
  (readAutoTestConfig/setAutoTestConfig)、开关设置(setAutoHeartRate/BP/Temp)、
  体温自动数据读取(readAutoTemperatureData),共 10 个新 API
- 新增 SDK 事件类型:SDK_EVENT_SLEEP(4)、SDK_EVENT_AUTO_TEST(54)
- VeepooPipeline 新增:readSleepData/readAllSleepData(enableAutoMeasurement
  睡眠数据 Promise 化读取 + 自动测量一键开启
- VeepooHistoryReader 新增:uploadSleepReadings 睡眠数据上传
- stores/veepoo.ts 实装:注册 onSleepData 回调、syncHistory 实际读取+上传、
  readSleepData 状态管理、enableAutoMeasurement、连接后自动触发三件事
- 原生页面(native/pkg-veepoo):_onReady 后自动读取 3 天睡眠 + 开启自动测量,
  新增 _readSleepData/_handleSleepEvent/_enableAutoMeasurement
- UI 重构:测量页药丸式选择器+SVG 圆环仪表盘+健康评估标签
- 数据上传页:2 列结果卡片网格+彩色条标识+睡眠数据卡片(★评分+总时长)
- 修复上传按钮无响应 bug:patientId 增加 URL fallback + 错误提示不再静默
- 设计原型:docs/design/veepoo-measure-prototype.html(4 状态预览)
2026-05-31 21:48:06 +08:00

246 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Veepoo 历史数据读取器 — 3天日常数据分批读取 + 上传
*
* SDK 日常数据格式type=5
* - 包含计步、心率、血压、血氧、睡眠、压力、体温等
* - Progress 字段 1-100% 表示读取进度
* - 每次回调可能包含一包数据
*/
import Taro from '@tarojs/taro';
import { readDailyData } from '../VeepooBridge';
import type { SdkEventData } from '../VeepooBridge';
import type { NormalizedReading } from '../types';
import type { SleepReading } from './types';
import { uploadReadings } from '@/services/device-sync';
const CHECKPOINT_KEY = 'veepoo_history_checkpoint';
const UPLOAD_BATCH_SIZE = 20;
interface Checkpoint {
lastProgress: number;
packagesRead: number;
deviceId: string;
timestamp: number;
}
export type HistoryReadPhase = 'idle' | 'reading' | 'uploading' | 'done' | 'error';
export class VeepooHistoryReader {
private phase: HistoryReadPhase = 'idle';
private progress = 0;
private packagesRead = 0;
private buffer: NormalizedReading[] = [];
private day = 0;
private patientId = '';
private deviceId = '';
private onProgress?: (progress: number, phase: HistoryReadPhase) => void;
private uploadedCount = 0;
setCallbacks(cbs: { onProgress?: (progress: number, phase: HistoryReadPhase) => void }): void {
this.onProgress = cbs.onProgress;
}
/** 开始读取3天数据 */
async startRead(patientId: string, deviceId: string): Promise<number> {
this.patientId = patientId;
this.deviceId = deviceId;
this.buffer = [];
this.uploadedCount = 0;
this.phase = 'reading';
// 依次读取 3 天数据
for (let day = 0; day < 3; day++) {
this.day = day;
this.progress = 0;
this.onProgress?.(0, 'reading');
await this.readDay(day);
// 刷新剩余 buffer
if (this.buffer.length > 0) {
await this.flushBuffer();
}
}
this.phase = 'done';
this.onProgress?.(100, 'done');
this.clearCheckpoint();
return this.uploadedCount;
}
/** 读取单天数据 */
private readDay(day: number): Promise<void> {
return new Promise((resolve) => {
// 发送读取指令
readDailyData(day, 1);
// 进度通过 handleDailyEvent 更新
// Progress=100 时 resolve
this.dayResolve = resolve;
// 超时保护30s
this.dayTimeout = setTimeout(() => {
this.dayResolve = null;
resolve();
}, 30_000);
});
}
private dayResolve: (() => void) | null = null;
private dayTimeout: ReturnType<typeof setTimeout> | null = null;
/** 处理 SDK 日常数据回调 */
handleDailyEvent(data: SdkEventData): void {
if (this.phase !== 'reading') return;
const progress = (data.Progress ?? 0) as number;
this.progress = progress;
this.onProgress?.(progress, 'reading');
// 解析数据
const readings = this.parseDailyData(data);
if (readings.length > 0) {
this.buffer.push(...readings);
this.packagesRead++;
}
// 达到批量大小就上传
if (this.buffer.length >= UPLOAD_BATCH_SIZE) {
this.flushBuffer();
}
// 进度 100% 表示当天数据读取完成
if (progress >= 100) {
if (this.dayTimeout) clearTimeout(this.dayTimeout);
this.dayTimeout = null;
const resolve = this.dayResolve;
this.dayResolve = null;
resolve?.();
}
}
/** 解析 SDK 日常数据为 NormalizedReading */
private parseDailyData(data: SdkEventData): NormalizedReading[] {
const content = data.content ?? {};
const readings: NormalizedReading[] = [];
const now = new Date();
// 偏移到对应天
const baseDate = new Date(now.getTime() - this.day * 86400000);
const timestamp = baseDate.toISOString();
// 心率
const hr = content.heartReat ?? content.heartRate;
if (typeof hr === 'number' && hr >= 30 && hr <= 250) {
readings.push({ device_type: 'heart_rate', values: { heart_rate: hr }, measured_at: timestamp });
}
// 血氧
const bo = content.bloodOxygen;
if (typeof bo === 'number' && bo >= 70 && bo <= 100) {
readings.push({ device_type: 'blood_oxygen', values: { blood_oxygen: bo }, measured_at: timestamp });
}
// 血压
const bph = content.bloodPressureHigh;
const bpl = content.bloodPressureLow;
if (typeof bph === 'number' && typeof bpl === 'number' && bph > 0 && bpl > 0) {
readings.push({ device_type: 'blood_pressure', values: { systolic: bph, diastolic: bpl }, measured_at: timestamp });
}
// 体温
const temp = content.bodyTemperature;
if (typeof temp === 'number' && temp > 30 && temp < 45) {
readings.push({ device_type: 'temperature', values: { temperature: temp }, measured_at: timestamp });
}
// 压力
const pressure = content.pressure;
if (typeof pressure === 'number' && pressure >= 0 && pressure <= 100) {
readings.push({ device_type: 'stress', values: { value: pressure }, measured_at: timestamp });
}
// 步数
const steps = content.stepCount ?? content.steps;
if (typeof steps === 'number' && steps >= 0) {
readings.push({ device_type: 'steps', values: { value: steps }, measured_at: timestamp });
}
return readings;
}
/** 上传 buffer 中的数据 */
private async flushBuffer(): Promise<void> {
if (this.buffer.length === 0) return;
const batch = this.buffer.splice(0, this.buffer.length);
this.phase = 'uploading';
this.onProgress?.(this.progress, 'uploading');
try {
await uploadReadings(this.patientId, this.deviceId, 'Veepoo M2', batch);
this.uploadedCount += batch.length;
this.saveCheckpoint();
} catch {
// 上传失败,放回 buffer
this.buffer.unshift(...batch);
}
this.phase = 'reading';
}
private saveCheckpoint(): void {
try {
const checkpoint: Checkpoint = {
lastProgress: this.progress,
packagesRead: this.packagesRead,
deviceId: this.deviceId,
timestamp: Date.now(),
};
Taro.setStorageSync(CHECKPOINT_KEY, JSON.stringify(checkpoint));
} catch { /* ignore */ }
}
private clearCheckpoint(): void {
try { Taro.removeStorageSync(CHECKPOINT_KEY); } catch { /* ignore */ }
}
getPhase(): HistoryReadPhase { return this.phase; }
getProgress(): number { return this.progress; }
getUploadedCount(): number { return this.uploadedCount; }
// ── 睡眠数据上传 ──
/** 将睡眠数据转换为 NormalizedReading 并上传 */
async uploadSleepReadings(patientId: string, deviceId: string, sleepData: SleepReading[]): Promise<number> {
if (sleepData.length === 0) return 0;
const now = new Date();
const readings: NormalizedReading[] = sleepData.map((sleep) => {
// 根据天数偏移计算日期
const baseDate = new Date(now.getTime() - sleep.day * 86400000);
return {
device_type: 'sleep',
values: {
deep_sleep_minutes: sleep.deepSleepMinutes,
light_sleep_minutes: sleep.lightSleepMinutes,
total_sleep_minutes: sleep.totalSleepMinutes,
quality_score: sleep.qualityScore,
},
measured_at: baseDate.toISOString(),
};
});
try {
await uploadReadings(patientId, deviceId, 'Veepoo M2', readings);
this.uploadedCount += readings.length;
console.log('[veepoo-history] 睡眠数据上传成功:', readings.length, '条');
return readings.length;
} catch (err) {
console.error('[veepoo-history] 睡眠数据上传失败:', err);
return 0;
}
}
}