fix(health): 设备数据管线 Phase 1 缺陷修复 + AI 产品策略讨论
- device_readings 批量插入添加 ON CONFLICT 去重唯一索引 - 小程序 BLEManager 增加离线缓存(Storage 持久化 + 启动重传) - 新增 device_readings 90 天数据保留清理定时任务 - 小米手环适配器增加 RACP 历史心率读取支持 - SSE 告警按医生过滤已确认实现(patient_doctor_relation) - 新增 AI 产品策略与设备数据医院场景讨论记录
This commit is contained in:
@@ -9,6 +9,9 @@ import type {
|
||||
BLEManagerConfig,
|
||||
} from './types';
|
||||
|
||||
const CACHE_KEY = 'ble_pending_readings';
|
||||
const CACHE_MAX = 2000;
|
||||
|
||||
const DEFAULT_CONFIG: BLEManagerConfig = {
|
||||
scanTimeout: 10000,
|
||||
maxReadingsPerSync: 500,
|
||||
@@ -179,6 +182,7 @@ export class BLEManager {
|
||||
);
|
||||
if (newReadings.length > 0) {
|
||||
this.readings = [...this.readings, ...newReadings];
|
||||
this.persistPendingReadings();
|
||||
this.onReadings?.(newReadings);
|
||||
}
|
||||
});
|
||||
@@ -248,6 +252,7 @@ export class BLEManager {
|
||||
try {
|
||||
const uploaded = await uploadFn(batch);
|
||||
this.readings = this.readings.slice(batch.length);
|
||||
this.clearPendingReadings();
|
||||
this.updateState('connected');
|
||||
return {
|
||||
success: true,
|
||||
@@ -303,6 +308,50 @@ export class BLEManager {
|
||||
clearReadings(): void {
|
||||
this.readings = [];
|
||||
}
|
||||
|
||||
// ── 离线缓存 ──
|
||||
|
||||
/** 将当前未上传读数同步写入 Storage */
|
||||
private persistPendingReadings(): void {
|
||||
try {
|
||||
const batch = this.readings.slice(-CACHE_MAX);
|
||||
Taro.setStorageSync(CACHE_KEY, JSON.stringify(batch));
|
||||
} catch {
|
||||
// Storage 写入失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传成功后清除缓存 */
|
||||
private clearPendingReadings(): void {
|
||||
try {
|
||||
Taro.removeStorageSync(CACHE_KEY);
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动时检查缓存,有未上传数据则自动重传。
|
||||
* 返回重传的记录数 */
|
||||
async flushPendingReadings(
|
||||
uploadFn: (readings: NormalizedReading[]) => Promise<number>,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(CACHE_KEY) as string;
|
||||
if (!raw) return 0;
|
||||
const cached: NormalizedReading[] = JSON.parse(raw);
|
||||
if (!Array.isArray(cached) || cached.length === 0) {
|
||||
Taro.removeStorageSync(CACHE_KEY);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const batch = cached.slice(0, this.config.maxReadingsPerSync);
|
||||
const uploaded = await uploadFn(batch);
|
||||
Taro.removeStorageSync(CACHE_KEY);
|
||||
return uploaded;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BLEManager();
|
||||
|
||||
@@ -4,15 +4,34 @@ import type { DeviceAdapter, NormalizedReading } from '../types';
|
||||
* 小米手环 BLE 适配器
|
||||
*
|
||||
* 支持 Mi Band 7/8 等型号,使用标准 Heart Rate Service 读取心率数据。
|
||||
* 未来可扩展步数(0xFEE1)、睡眠等 Mi Band 专有 Service。
|
||||
* 支持 RACP (Record Access Control Point) 读取历史心率记录。
|
||||
*/
|
||||
|
||||
// 标准 BLE Heart Rate Service
|
||||
const HRS_SERVICE = '0000180D-0000-1000-8000-00805F9B34FB';
|
||||
const HRM_CHARACTERISTIC = '00002A37-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
// 小米 Mi Band 专有 Service(步数/活动量)
|
||||
// const MI_BAND_SERVICE = '0000FEE1-0000-1000-8000-8000-00805F9B34FB';
|
||||
// Heart Rate Service — Body Sensor Location
|
||||
const BODY_SENSOR_LOCATION = '00002A38-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
// Record Access Control Point — 用于读取历史心率记录
|
||||
const RACP_CHARACTERISTIC = '00002A52-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
// RACP 操作码
|
||||
const RACP_OPCODE_REPORT_STORED_RECORDS = 0x01;
|
||||
const RACP_OPCODE_DELETE_STORED_RECORDS = 0x02;
|
||||
const RACP_OPCODE_ABORT_OPERATION = 0x03;
|
||||
const RACP_OPCODE_REPORT_NUMBER_OF_RECORDS = 0x04;
|
||||
const RACP_OPCODE_NUMBER_OF_STORED_RECORDS_RESPONSE = 0x05;
|
||||
const RACP_OPCODE_RESPONSE_CODE = 0x06;
|
||||
|
||||
// RACP 操作符
|
||||
const RACP_OPERATOR_ALL = 0x01;
|
||||
const RACP_OPERATOR_LESS_THAN = 0x04;
|
||||
const RACP_OPERATOR_GREATER_THAN = 0x05;
|
||||
|
||||
// RACP 过滤器类型
|
||||
const RACP_FILTER_TYPE_TIME = 0x01;
|
||||
|
||||
/** 解析心率测量值(Heart Rate Measurement 格式) */
|
||||
function parseHeartRate(data: ArrayBuffer): number | null {
|
||||
@@ -50,6 +69,8 @@ export const XiaomiBandAdapter: DeviceAdapter = {
|
||||
|
||||
readCharacteristics: [
|
||||
{ service: HRS_SERVICE, characteristic: HRM_CHARACTERISTIC },
|
||||
{ service: HRS_SERVICE, characteristic: BODY_SENSOR_LOCATION },
|
||||
{ service: HRS_SERVICE, characteristic: RACP_CHARACTERISTIC },
|
||||
],
|
||||
|
||||
parseNotification(
|
||||
@@ -72,12 +93,78 @@ export const XiaomiBandAdapter: DeviceAdapter = {
|
||||
|
||||
parseReadResponse(
|
||||
_serviceUUID: string,
|
||||
_charUUID: string,
|
||||
_data: ArrayBuffer,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading[] {
|
||||
// 读取模式暂不支持,使用通知模式获取数据
|
||||
const upper = charUUID.toUpperCase();
|
||||
|
||||
if (upper.includes('2A37')) {
|
||||
// Heart Rate Measurement — 通过 read 触发的回调
|
||||
const hr = parseHeartRate(data);
|
||||
if (hr !== null && hr > 0 && hr < 300) {
|
||||
return [{
|
||||
device_type: 'heart_rate',
|
||||
values: { heart_rate: hr },
|
||||
measured_at: new Date().toISOString(),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
if (upper.includes('2A52')) {
|
||||
// RACP 响应
|
||||
const view = new DataView(data);
|
||||
if (view.byteLength < 3) return [];
|
||||
|
||||
const opcode = view.getUint8(0);
|
||||
|
||||
if (opcode === RACP_OPCODE_NUMBER_OF_STORED_RECORDS_RESPONSE) {
|
||||
const count = view.getUint16(2, true);
|
||||
// 不返回 reading,仅用于日志/调试
|
||||
return [];
|
||||
}
|
||||
|
||||
if (opcode === RACP_OPCODE_RESPONSE_CODE) {
|
||||
const reqOpcode = view.getUint8(1);
|
||||
const status = view.getUint8(2);
|
||||
// status: 0x01=成功, 0x02=不支持的操作码, 等
|
||||
// 不返回 reading,仅用于日志
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 构建 RACP "Report All Stored Records" 请求命令。
|
||||
* 写入 RACP characteristic 触发设备通过 HRM characteristic 回传历史心率。
|
||||
*
|
||||
* 用法:
|
||||
* Taro.writeBLECharacteristicValue({
|
||||
* deviceId, serviceId: HRS_SERVICE,
|
||||
* characteristicId: RACP_CHARACTERISTIC,
|
||||
* value: buildRACPReportAll(),
|
||||
* })
|
||||
*/
|
||||
export function buildRACPReportAll(): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(2);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, RACP_OPCODE_REPORT_STORED_RECORDS);
|
||||
view.setUint8(1, RACP_OPERATOR_ALL);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 RACP "Report Number of Stored Records" 请求命令。
|
||||
* 返回设备中存储的记录数。
|
||||
*/
|
||||
export function buildRACPReportCount(): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(2);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, RACP_OPCODE_REPORT_NUMBER_OF_STORED_RECORDS);
|
||||
view.setUint8(1, RACP_OPERATOR_ALL);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export default XiaomiBandAdapter;
|
||||
|
||||
Reference in New Issue
Block a user