- 新增 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 状态预览)
246 lines
7.4 KiB
TypeScript
246 lines
7.4 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
}
|
||
}
|