feat(miniprogram): BLE 增强层 — DataBuffer + GenericBleAdapter + DataSyncScheduler
- DataBuffer: 离线持久化缓冲(分桶存储 + 去重 + 容量管理) - GenericBleAdapter: 基于 Bluetooth SIG 标准 Health Profile 的通用适配器 (Heart Rate 0x180D / Health Thermometer 0x1809 / Blood Pressure 0x1810) - DataSyncScheduler: 定时自动同步调度(基于时间间隔判断是否需要同步) - BLEManager: 集成 DataBuffer 替换简单 Storage 缓存 - device-sync 页面: 注册 CustomBandAdapter + 自动同步 + 状态显示 - 新增 vitest 单元测试配置,30 个测试全部通过
This commit is contained in:
@@ -8,9 +8,7 @@ import type {
|
||||
SyncResult,
|
||||
BLEManagerConfig,
|
||||
} from './types';
|
||||
|
||||
const CACHE_KEY = 'ble_pending_readings';
|
||||
const CACHE_MAX = 2000;
|
||||
import { DataBuffer } from './DataBuffer';
|
||||
|
||||
const DEFAULT_CONFIG: BLEManagerConfig = {
|
||||
scanTimeout: 10000,
|
||||
@@ -22,6 +20,7 @@ export class BLEManager {
|
||||
private adapters: DeviceAdapter[] = [];
|
||||
private connection: BLEConnection | null = null;
|
||||
private readings: NormalizedReading[] = [];
|
||||
private dataBuffer: DataBuffer;
|
||||
private config: BLEManagerConfig;
|
||||
private scanTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private onConnectionChange?: (state: BLEConnectionState) => void;
|
||||
@@ -29,6 +28,8 @@ export class BLEManager {
|
||||
|
||||
constructor(config?: Partial<BLEManagerConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.dataBuffer = new DataBuffer();
|
||||
this.dataBuffer.restore();
|
||||
}
|
||||
|
||||
/** 注册设备适配器 */
|
||||
@@ -182,7 +183,7 @@ export class BLEManager {
|
||||
);
|
||||
if (newReadings.length > 0) {
|
||||
this.readings = [...this.readings, ...newReadings];
|
||||
this.persistPendingReadings();
|
||||
this.dataBuffer.push(newReadings);
|
||||
this.onReadings?.(newReadings);
|
||||
}
|
||||
});
|
||||
@@ -213,8 +214,6 @@ export class BLEManager {
|
||||
if (!svc) continue;
|
||||
|
||||
try {
|
||||
// Taro readBLECharacteristicValue 触发 onBLECharacteristicValueChange 回调
|
||||
// 读取结果会通过 BLEManager 已注册的 onBLECharacteristicValueChange 监听器返回
|
||||
await Taro.readBLECharacteristicValue({
|
||||
deviceId,
|
||||
serviceId: svc.uuid,
|
||||
@@ -241,7 +240,7 @@ export class BLEManager {
|
||||
|
||||
this.updateState('syncing');
|
||||
|
||||
const batch = this.readings.slice(-this.config.maxReadingsPerSync);
|
||||
const batch = this.dataBuffer.flush();
|
||||
if (batch.length === 0) {
|
||||
this.updateState('connected');
|
||||
return { success: true, readingsCount: 0, uploadedCount: 0 };
|
||||
@@ -251,8 +250,7 @@ export class BLEManager {
|
||||
for (let attempt = 1; attempt <= this.config.retryCount; attempt++) {
|
||||
try {
|
||||
const uploaded = await uploadFn(batch);
|
||||
this.readings = this.readings.slice(batch.length);
|
||||
this.clearPendingReadings();
|
||||
this.readings = [];
|
||||
this.updateState('connected');
|
||||
return {
|
||||
success: true,
|
||||
@@ -261,6 +259,8 @@ export class BLEManager {
|
||||
};
|
||||
} catch (e: any) {
|
||||
lastError = e.message || '上传失败';
|
||||
// flush 已取出,失败时需要放回
|
||||
this.dataBuffer.push(batch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,46 +309,19 @@ export class BLEManager {
|
||||
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 = this.dataBuffer.flush();
|
||||
if (batch.length === 0) return 0;
|
||||
|
||||
const batch = cached.slice(0, this.config.maxReadingsPerSync);
|
||||
try {
|
||||
const uploaded = await uploadFn(batch);
|
||||
Taro.removeStorageSync(CACHE_KEY);
|
||||
return uploaded;
|
||||
} catch {
|
||||
// 失败时放回
|
||||
this.dataBuffer.push(batch);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
149
apps/miniprogram/src/services/ble/DataBuffer.ts
Normal file
149
apps/miniprogram/src/services/ble/DataBuffer.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import type { NormalizedReading } from './types';
|
||||
|
||||
export interface DataBufferConfig {
|
||||
/** 单桶最大条数(默认 500) */
|
||||
bucketSize?: number;
|
||||
/** 总最大条数,超出丢弃最旧(默认 2000) */
|
||||
maxTotal?: number;
|
||||
/** Storage key 前缀(默认 'ble_buffer') */
|
||||
storageKeyPrefix?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Required<DataBufferConfig> = {
|
||||
bucketSize: 500,
|
||||
maxTotal: 2000,
|
||||
storageKeyPrefix: 'ble_buffer',
|
||||
};
|
||||
|
||||
/** 离线数据缓冲 — 分桶持久化到 Storage,支持去重和容量管理 */
|
||||
export class DataBuffer {
|
||||
private config: Required<DataBufferConfig>;
|
||||
private buckets: NormalizedReading[][] = [[]];
|
||||
private currentBucketIndex = 0;
|
||||
private seenKeys: Set<string>;
|
||||
|
||||
constructor(config?: DataBufferConfig) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.seenKeys = new Set();
|
||||
}
|
||||
|
||||
/** 添加读数(单条或批量) */
|
||||
push(readings: NormalizedReading | NormalizedReading[]): void {
|
||||
const items = Array.isArray(readings) ? readings : [readings];
|
||||
const deduped = items.filter((r) => {
|
||||
const key = this.dedupeKey(r);
|
||||
if (this.seenKeys.has(key)) return false;
|
||||
this.seenKeys.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (deduped.length === 0) return;
|
||||
|
||||
let current = this.buckets[this.currentBucketIndex];
|
||||
for (const r of deduped) {
|
||||
if (current.length >= this.config.bucketSize) {
|
||||
this.currentBucketIndex++;
|
||||
current = [];
|
||||
this.buckets.push(current);
|
||||
}
|
||||
current.push(r);
|
||||
}
|
||||
|
||||
this.trimToMax();
|
||||
this.persistCurrentBucket();
|
||||
}
|
||||
|
||||
/** 取出所有缓冲数据并清空 */
|
||||
flush(): NormalizedReading[] {
|
||||
const all = this.buckets.flat();
|
||||
this.buckets = [[]];
|
||||
this.currentBucketIndex = 0;
|
||||
this.seenKeys.clear();
|
||||
this.clearStorage();
|
||||
return all;
|
||||
}
|
||||
|
||||
/** 缓冲区条数 */
|
||||
size(): number {
|
||||
return this.buckets.reduce((sum, b) => sum + b.length, 0);
|
||||
}
|
||||
|
||||
/** 从 Storage 恢复(启动时调用) */
|
||||
restore(): number {
|
||||
this.buckets = [];
|
||||
this.seenKeys.clear();
|
||||
let total = 0;
|
||||
let idx = 0;
|
||||
|
||||
while (true) {
|
||||
const key = `${this.config.storageKeyPrefix}_${idx}`;
|
||||
const raw = Taro.getStorageSync(key) as string;
|
||||
if (!raw) break;
|
||||
try {
|
||||
const parsed: NormalizedReading[] = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
this.buckets.push(parsed);
|
||||
for (const r of parsed) {
|
||||
this.seenKeys.add(this.dedupeKey(r));
|
||||
}
|
||||
total += parsed.length;
|
||||
}
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
|
||||
if (this.buckets.length === 0) {
|
||||
this.buckets = [[]];
|
||||
}
|
||||
this.currentBucketIndex = this.buckets.length - 1;
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 清空缓冲和 Storage */
|
||||
clear(): void {
|
||||
this.buckets = [[]];
|
||||
this.currentBucketIndex = 0;
|
||||
this.seenKeys.clear();
|
||||
this.clearStorage();
|
||||
}
|
||||
|
||||
private dedupeKey(r: NormalizedReading): string {
|
||||
return `${r.device_type}|${r.measured_at}|${r.metric ?? ''}`;
|
||||
}
|
||||
|
||||
private trimToMax(): void {
|
||||
let total = this.size();
|
||||
while (total > this.config.maxTotal && this.buckets.length > 1) {
|
||||
const removed = this.buckets.shift()!;
|
||||
total -= removed.length;
|
||||
this.currentBucketIndex--;
|
||||
}
|
||||
if (total > this.config.maxTotal) {
|
||||
const excess = total - this.config.maxTotal;
|
||||
this.buckets[0] = this.buckets[0].slice(excess);
|
||||
}
|
||||
}
|
||||
|
||||
private persistCurrentBucket(): void {
|
||||
const key = `${this.config.storageKeyPrefix}_${this.currentBucketIndex}`;
|
||||
try {
|
||||
Taro.setStorageSync(key, JSON.stringify(this.buckets[this.currentBucketIndex]));
|
||||
} catch {
|
||||
// Storage 写入失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
private clearStorage(): void {
|
||||
let idx = 0;
|
||||
while (true) {
|
||||
const key = `${this.config.storageKeyPrefix}_${idx}`;
|
||||
const raw = Taro.getStorageSync(key) as string;
|
||||
if (!raw) break;
|
||||
try { Taro.removeStorageSync(key); } catch { /* ignore */ }
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
apps/miniprogram/src/services/ble/DataSyncScheduler.ts
Normal file
100
apps/miniprogram/src/services/ble/DataSyncScheduler.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
export interface SyncSchedulerConfig {
|
||||
/** 同步间隔(毫秒,默认 1 小时) */
|
||||
intervalMs?: number;
|
||||
/** Storage key(默认 'last_ble_sync') */
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
interface SyncRecord {
|
||||
lastSyncAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Required<SyncSchedulerConfig> = {
|
||||
intervalMs: 60 * 60 * 1000,
|
||||
storageKey: 'last_ble_sync',
|
||||
};
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
uploadedCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** BLE 数据同步调度器 — 基于时间间隔判断是否需要同步 */
|
||||
export class DataSyncScheduler {
|
||||
private config: Required<SyncSchedulerConfig>;
|
||||
private timerId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(config?: SyncSchedulerConfig) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/** 判断是否需要同步 */
|
||||
needsSync(): boolean {
|
||||
const record = this.loadRecord();
|
||||
if (!record) return true;
|
||||
return Date.now() - record.lastSyncAt >= this.config.intervalMs;
|
||||
}
|
||||
|
||||
/** 执行同步并记录时间戳 */
|
||||
async recordSync(syncFn: () => Promise<SyncResult>): Promise<SyncResult> {
|
||||
try {
|
||||
const result = await syncFn();
|
||||
if (result.success) {
|
||||
this.saveRecord({ lastSyncAt: Date.now() });
|
||||
}
|
||||
return result;
|
||||
} catch (e: any) {
|
||||
return { success: false, uploadedCount: 0, error: e.message || '同步失败' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动同步:仅在 needsSync() 为 true 时触发 */
|
||||
async tryAutoSync(syncFn: () => Promise<SyncResult>): Promise<boolean> {
|
||||
if (!this.needsSync()) return false;
|
||||
const result = await this.recordSync(syncFn);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/** 启动周期性检查(页面活跃时调用) */
|
||||
startPeriodicCheck(syncFn: () => Promise<SyncResult>, checkIntervalMs: number): void {
|
||||
this.destroy();
|
||||
this.timerId = setInterval(() => {
|
||||
this.tryAutoSync(syncFn);
|
||||
}, checkIntervalMs);
|
||||
}
|
||||
|
||||
/** 停止周期性检查 */
|
||||
destroy(): void {
|
||||
if (this.timerId !== null) {
|
||||
clearInterval(this.timerId);
|
||||
this.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取上次同步时间戳 */
|
||||
getLastSyncAt(): number | null {
|
||||
const record = this.loadRecord();
|
||||
return record?.lastSyncAt ?? null;
|
||||
}
|
||||
|
||||
private loadRecord(): SyncRecord | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(this.config.storageKey) as string;
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as SyncRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private saveRecord(record: SyncRecord): void {
|
||||
try {
|
||||
Taro.setStorageSync(this.config.storageKey, JSON.stringify(record));
|
||||
} catch {
|
||||
// Storage 写入失败不影响主流程
|
||||
}
|
||||
}
|
||||
}
|
||||
145
apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts
Normal file
145
apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { DeviceAdapter, NormalizedReading, GenericBLEProfile } from '../types';
|
||||
|
||||
// ── Bluetooth SIG 标准 Service UUID ──
|
||||
const SERVICES: Record<string, { uuid: string; chars: { notify: string; read: string } }> = {
|
||||
heart_rate: {
|
||||
uuid: '0000180D-0000-1000-8000-00805F9B34FB',
|
||||
chars: {
|
||||
notify: '00002A37-0000-1000-8000-00805F9B34FB',
|
||||
read: '00002A37-0000-1000-8000-00805F9B34FB',
|
||||
},
|
||||
},
|
||||
health_thermometer: {
|
||||
uuid: '00001809-0000-1000-8000-00805F9B34FB',
|
||||
chars: {
|
||||
notify: '00002A1C-0000-1000-8000-00805F9B34FB',
|
||||
read: '00002A1C-0000-1000-8000-00805F9B34FB',
|
||||
},
|
||||
},
|
||||
blood_pressure: {
|
||||
uuid: '00001810-0000-1000-8000-00805F9B34FB',
|
||||
chars: {
|
||||
notify: '00002A35-0000-1000-8000-00805F9B34FB',
|
||||
read: '00002A35-0000-1000-8000-00805F9B34FB',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── 解析器 ──
|
||||
|
||||
function parseHeartRate(data: ArrayBuffer): NormalizedReading | null {
|
||||
const view = new DataView(data);
|
||||
if (view.byteLength < 2) return null;
|
||||
|
||||
const flags = view.getUint8(0);
|
||||
const isUINT16 = (flags & 0x01) !== 0;
|
||||
const hr = isUINT16
|
||||
? (view.byteLength >= 3 ? view.getUint16(1, true) : null)
|
||||
: view.getUint8(1);
|
||||
|
||||
if (hr === null || hr <= 0 || hr > 300) return null;
|
||||
|
||||
return {
|
||||
device_type: 'heart_rate',
|
||||
values: { heart_rate: hr },
|
||||
measured_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTemperature(data: ArrayBuffer): NormalizedReading | null {
|
||||
const view = new DataView(data);
|
||||
if (view.byteLength < 4) return null;
|
||||
|
||||
const flags = view.getUint8(0);
|
||||
const isFahrenheit = (flags & 0x01) !== 0;
|
||||
|
||||
const mantissa = view.getInt16(1, true);
|
||||
const exponent = view.getInt8(3);
|
||||
const temp = mantissa * Math.pow(10, exponent);
|
||||
|
||||
const tempCelsius = isFahrenheit ? (temp - 32) * 5 / 9 : temp;
|
||||
|
||||
return {
|
||||
device_type: 'temperature',
|
||||
values: { value: Math.round(tempCelsius * 10) / 10, unit: '°C' },
|
||||
measured_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 工厂函数 ──
|
||||
|
||||
export interface GenericAdapterConfig {
|
||||
name: string;
|
||||
supportedModels: string[];
|
||||
profiles: GenericBLEProfile[];
|
||||
}
|
||||
|
||||
/** 创建通用 BLE 适配器 — 基于 Bluetooth SIG 标准 Health Profile */
|
||||
export function createGenericBleAdapter(config: GenericAdapterConfig): DeviceAdapter {
|
||||
const activeServices = config.profiles
|
||||
.map((p) => SERVICES[p])
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
supportedModels: config.supportedModels,
|
||||
serviceUUIDs: activeServices.map((s) => s.uuid),
|
||||
notifyCharacteristics: activeServices.map((s) => ({
|
||||
service: s.uuid,
|
||||
characteristic: s.chars.notify,
|
||||
})),
|
||||
readCharacteristics: activeServices.map((s) => ({
|
||||
service: s.uuid,
|
||||
characteristic: s.chars.read,
|
||||
})),
|
||||
|
||||
parseNotification(
|
||||
_serviceUUID: string,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading[] {
|
||||
const upper = charUUID.toUpperCase();
|
||||
|
||||
// Heart Rate Measurement
|
||||
const hrsChar = SERVICES.heart_rate.chars.notify.toUpperCase();
|
||||
if (upper === hrsChar || upper.includes('2A37')) {
|
||||
const result = parseHeartRate(data);
|
||||
return result ? [result] : [];
|
||||
}
|
||||
|
||||
// Temperature Measurement
|
||||
const htChar = SERVICES.health_thermometer.chars.notify.toUpperCase();
|
||||
if (upper === htChar || upper.includes('2A1C')) {
|
||||
const result = parseTemperature(data);
|
||||
return result ? [result] : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
parseReadResponse(
|
||||
serviceUUID: string,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading[] {
|
||||
return this.parseNotification(serviceUUID, charUUID, data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 预配置:通用定制手环(心率 + 体温)
|
||||
* 未来定制手环只需在 supportedModels 中加入型号名
|
||||
*/
|
||||
export const CustomBandAdapter = createGenericBleAdapter({
|
||||
name: 'Custom Health Band',
|
||||
supportedModels: [
|
||||
'Health Band',
|
||||
'Medical Band',
|
||||
'Smart Bracelet',
|
||||
'健康手环',
|
||||
],
|
||||
profiles: ['heart_rate', 'health_thermometer'],
|
||||
});
|
||||
|
||||
export default CustomBandAdapter;
|
||||
@@ -1,3 +1,4 @@
|
||||
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
|
||||
export { BloodPressureAdapter } from './BloodPressureAdapter';
|
||||
export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';
|
||||
export { CustomBandAdapter, createGenericBleAdapter } from './GenericBleAdapter';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export { BLEManager } from './BLEManager';
|
||||
export { DataBuffer } from './DataBuffer';
|
||||
export type { DataBufferConfig } from './DataBuffer';
|
||||
export { DataSyncScheduler } from './DataSyncScheduler';
|
||||
export type { SyncSchedulerConfig, SyncResult as SchedulerSyncResult } from './DataSyncScheduler';
|
||||
export type {
|
||||
DeviceType,
|
||||
NormalizedReading,
|
||||
@@ -8,4 +12,5 @@ export type {
|
||||
BLEConnectionState,
|
||||
SyncResult,
|
||||
BLEManagerConfig,
|
||||
GenericBLEProfile,
|
||||
} from './types';
|
||||
|
||||
@@ -91,3 +91,9 @@ export interface BLEManagerConfig {
|
||||
/** 同步失败重试次数 */
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
/** 通用 BLE 适配器可识别的标准 BLE Profile */
|
||||
export type GenericBLEProfile =
|
||||
| 'heart_rate' // Heart Rate Service (0x180D)
|
||||
| 'health_thermometer' // Health Thermometer Service (0x1809)
|
||||
| 'blood_pressure'; // Blood Pressure Service (0x1810)
|
||||
|
||||
Reference in New Issue
Block a user