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:
@@ -1,10 +1,12 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import { BLEManager } from '@/services/ble/BLEManager';
|
||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||
import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter';
|
||||
import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter';
|
||||
import { CustomBandAdapter } from '@/services/ble/adapters/GenericBleAdapter';
|
||||
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
|
||||
import { uploadReadings } from '@/services/device-sync';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
|
||||
@@ -14,6 +16,7 @@ const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
|
||||
bleManager.registerAdapter(XiaomiBandAdapter);
|
||||
bleManager.registerAdapter(BloodPressureAdapter);
|
||||
bleManager.registerAdapter(GlucoseMeterAdapter);
|
||||
bleManager.registerAdapter(CustomBandAdapter);
|
||||
|
||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||
|
||||
@@ -27,6 +30,12 @@ export default function DeviceSync() {
|
||||
const [liveReadings, setLiveReadings] = useState<NormalizedReading[]>([]);
|
||||
const [syncCount, setSyncCount] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const scheduler = useMemo(() => new DataSyncScheduler({
|
||||
intervalMs: 60 * 60 * 1000,
|
||||
}), []);
|
||||
|
||||
useDidShow(() => {
|
||||
bleManager.setOnConnectionChange(() => {});
|
||||
@@ -34,7 +43,29 @@ export default function DeviceSync() {
|
||||
setLiveReadings((prev) => [...prev, ...readings]);
|
||||
});
|
||||
|
||||
// 显示上次同步时间
|
||||
setLastSyncAt(scheduler.getLastSyncAt());
|
||||
|
||||
// 检查是否有未上传的缓冲数据
|
||||
const buffer = (bleManager as any).dataBuffer;
|
||||
if (buffer) {
|
||||
setPendingCount(buffer.size());
|
||||
}
|
||||
|
||||
// 自动同步:超过间隔时尝试上传缓冲数据
|
||||
if (currentPatient && scheduler.needsSync()) {
|
||||
scheduler.tryAutoSync(async () => {
|
||||
const count = await bleManager.flushPendingReadings(async (readings) => {
|
||||
return uploadReadings(currentPatient.id, 'buffered', undefined, readings);
|
||||
});
|
||||
setLastSyncAt(Date.now());
|
||||
setPendingCount(0);
|
||||
return { success: count > 0, uploadedCount: count };
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
scheduler.destroy();
|
||||
bleManager.destroy();
|
||||
};
|
||||
});
|
||||
@@ -87,6 +118,7 @@ export default function DeviceSync() {
|
||||
|
||||
if (result.success) {
|
||||
setSyncCount(result.uploadedCount);
|
||||
setLastSyncAt(Date.now());
|
||||
setPageState('done');
|
||||
|
||||
// 如果从体征录入页跳转而来,将最新读数写入 storage 供回填
|
||||
@@ -136,6 +168,21 @@ export default function DeviceSync() {
|
||||
<Text className="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</Text>
|
||||
</View>
|
||||
|
||||
{(lastSyncAt || pendingCount > 0) && (
|
||||
<View className="sync-status-info">
|
||||
{lastSyncAt && (
|
||||
<Text className="sync-status-time">
|
||||
上次同步: {new Date(lastSyncAt).toLocaleTimeString()}
|
||||
</Text>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<Text className="sync-status-pending">
|
||||
{pendingCount} 条数据待上传
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="sync-action" onClick={handleScan}>
|
||||
<Text className="sync-action-text">扫描设备</Text>
|
||||
</View>
|
||||
|
||||
@@ -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