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 状态预览)
This commit is contained in:
245
apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts
Normal file
245
apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user