feat(miniprogram): BLE 增强层 — DataBuffer + GenericBleAdapter + DataSyncScheduler
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 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:
iven
2026-05-04 02:42:58 +08:00
parent 70aacf47a0
commit 62c02e0f15
15 changed files with 1272 additions and 119 deletions

View File

@@ -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;
}
}

View 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++;
}
}
}

View 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 写入失败不影响主流程
}
}
}

View 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;

View File

@@ -1,3 +1,4 @@
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
export { BloodPressureAdapter } from './BloodPressureAdapter';
export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';
export { CustomBandAdapter, createGenericBleAdapter } from './GenericBleAdapter';

View File

@@ -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';

View File

@@ -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)