feat(miniprogram): BLE 设备同步模块 — 扫描+连接+数据上传
- Task 18: BLE 类型定义(NormalizedReading/DeviceAdapter/BLEDevice)+ BLEManager 连接管理器 - Task 19: XiaomiBandAdapter 心率读取适配器(标准 HRS Service 0x180D) - Task 20: device-sync API 层 + 设备同步页面 + app.config 路由注册
This commit is contained in:
308
apps/miniprogram/src/services/ble/BLEManager.ts
Normal file
308
apps/miniprogram/src/services/ble/BLEManager.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import type {
|
||||
DeviceAdapter,
|
||||
BLEDevice,
|
||||
BLEConnection,
|
||||
BLEConnectionState,
|
||||
NormalizedReading,
|
||||
SyncResult,
|
||||
BLEManagerConfig,
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_CONFIG: BLEManagerConfig = {
|
||||
scanTimeout: 10000,
|
||||
maxReadingsPerSync: 500,
|
||||
retryCount: 3,
|
||||
};
|
||||
|
||||
export class BLEManager {
|
||||
private adapters: DeviceAdapter[] = [];
|
||||
private connection: BLEConnection | null = null;
|
||||
private readings: NormalizedReading[] = [];
|
||||
private config: BLEManagerConfig;
|
||||
private scanTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private onConnectionChange?: (state: BLEConnectionState) => void;
|
||||
private onReadings?: (readings: NormalizedReading[]) => void;
|
||||
|
||||
constructor(config?: Partial<BLEManagerConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/** 注册设备适配器 */
|
||||
registerAdapter(adapter: DeviceAdapter): void {
|
||||
this.adapters = [...this.adapters, adapter];
|
||||
}
|
||||
|
||||
/** 设置连接状态回调 */
|
||||
setOnConnectionChange(cb: (state: BLEConnectionState) => void): void {
|
||||
this.onConnectionChange = cb;
|
||||
}
|
||||
|
||||
/** 设置读数回调 */
|
||||
setOnReadings(cb: (readings: NormalizedReading[]) => void): void {
|
||||
this.onReadings = cb;
|
||||
}
|
||||
|
||||
private updateState(state: BLEConnectionState, error?: string): void {
|
||||
if (this.connection) {
|
||||
this.connection = { ...this.connection, state, error };
|
||||
}
|
||||
this.onConnectionChange?.(state);
|
||||
}
|
||||
|
||||
/** 匹配设备到适配器 */
|
||||
private matchAdapter(deviceName: string): DeviceAdapter | undefined {
|
||||
const lower = deviceName.toLowerCase();
|
||||
return this.adapters.find((a) =>
|
||||
a.supportedModels.some((m) => lower.includes(m.toLowerCase())),
|
||||
);
|
||||
}
|
||||
|
||||
/** 初始化蓝牙适配器 */
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
await Taro.openBluetoothAdapter();
|
||||
} catch (e: any) {
|
||||
throw new Error(e.errMsg || '蓝牙初始化失败,请检查蓝牙是否开启');
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫描 BLE 设备 */
|
||||
async scanDevices(): Promise<BLEDevice[]> {
|
||||
await this.initialize();
|
||||
|
||||
const discovered = new Map<string, BLEDevice>();
|
||||
|
||||
const onFound = (res: any) => {
|
||||
for (const device of res.devices || []) {
|
||||
const name = device.name || device.localName || '';
|
||||
if (!name) continue;
|
||||
const adapter = this.matchAdapter(name);
|
||||
if (adapter) {
|
||||
discovered.set(device.deviceId, {
|
||||
deviceId: device.deviceId,
|
||||
name,
|
||||
RSSI: device.RSSI,
|
||||
localName: device.localName,
|
||||
advertisData: device.advertisData,
|
||||
adapter,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Taro.onBluetoothDeviceFound(onFound);
|
||||
|
||||
const allServiceUUIDs = this.adapters.flatMap((a) => a.serviceUUIDs);
|
||||
await Taro.startBluetoothDevicesDiscovery({
|
||||
allowDuplicatesKey: false,
|
||||
services: allServiceUUIDs.length > 0 ? allServiceUUIDs : undefined,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.scanTimer = setTimeout(async () => {
|
||||
await this.stopScan();
|
||||
Taro.offBluetoothDeviceFound(onFound);
|
||||
resolve(Array.from(discovered.values()));
|
||||
}, this.config.scanTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
/** 停止扫描 */
|
||||
async stopScan(): Promise<void> {
|
||||
if (this.scanTimer) {
|
||||
clearTimeout(this.scanTimer);
|
||||
this.scanTimer = null;
|
||||
}
|
||||
try {
|
||||
await Taro.stopBluetoothDevicesDiscovery();
|
||||
} catch {
|
||||
// 忽略停止扫描错误
|
||||
}
|
||||
}
|
||||
|
||||
/** 连接到设备 */
|
||||
async connect(device: BLEDevice): Promise<void> {
|
||||
if (!device.adapter) throw new Error('设备无适配器');
|
||||
|
||||
this.connection = {
|
||||
deviceId: device.deviceId,
|
||||
state: 'connecting',
|
||||
adapter: device.adapter,
|
||||
};
|
||||
this.updateState('connecting');
|
||||
this.readings = [];
|
||||
|
||||
try {
|
||||
await Taro.createBLEConnection({
|
||||
deviceId: device.deviceId,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// 监听断连
|
||||
Taro.onBLEConnectionStateChange((res: any) => {
|
||||
if (res.deviceId === device.deviceId && !res.connected) {
|
||||
this.updateState('disconnected', '设备断开连接');
|
||||
this.connection = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 发现服务
|
||||
const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId });
|
||||
const services = servicesRes.services || [];
|
||||
|
||||
// 启用通知
|
||||
for (const { service: svcUUID, characteristic: charUUID } of device.adapter.notifyCharacteristics) {
|
||||
const svc = services.find((s: any) => s.uuid.toUpperCase().includes(svcUUID.toUpperCase()));
|
||||
if (!svc) continue;
|
||||
|
||||
await Taro.getBLEDeviceCharacteristics({
|
||||
deviceId: device.deviceId,
|
||||
serviceId: svc.uuid,
|
||||
});
|
||||
|
||||
await Taro.notifyBLECharacteristicValueChange({
|
||||
deviceId: device.deviceId,
|
||||
serviceId: svc.uuid,
|
||||
characteristicId: charUUID,
|
||||
state: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 监听数据通知
|
||||
Taro.onBLECharacteristicValueChange((res: any) => {
|
||||
if (res.deviceId !== device.deviceId) return;
|
||||
const reading = device.adapter!.parseNotification(
|
||||
res.serviceId,
|
||||
res.characteristicId,
|
||||
res.value,
|
||||
);
|
||||
if (reading) {
|
||||
this.readings = [...this.readings, reading];
|
||||
this.onReadings?.([reading]);
|
||||
}
|
||||
});
|
||||
|
||||
this.connection = { ...this.connection, state: 'connected', connectedAt: Date.now() };
|
||||
this.updateState('connected');
|
||||
} catch (e: any) {
|
||||
this.updateState('error', e.errMsg || e.message || '连接失败');
|
||||
this.connection = null;
|
||||
throw new Error(e.errMsg || '蓝牙连接失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动读取特征值 */
|
||||
async readCharacteristics(): Promise<NormalizedReading[]> {
|
||||
if (!this.connection || this.connection.state !== 'connected') {
|
||||
throw new Error('设备未连接');
|
||||
}
|
||||
|
||||
const { deviceId, adapter } = this.connection;
|
||||
const results: NormalizedReading[] = [];
|
||||
|
||||
const servicesRes = await Taro.getBLEDeviceServices({ deviceId });
|
||||
const services = servicesRes.services || [];
|
||||
|
||||
for (const { service: svcUUID, characteristic: charUUID } of adapter.readCharacteristics) {
|
||||
const svc = services.find((s: any) => s.uuid.toUpperCase().includes(svcUUID.toUpperCase()));
|
||||
if (!svc) continue;
|
||||
|
||||
try {
|
||||
// Taro readBLECharacteristicValue 触发 onBLECharacteristicValueChange 回调
|
||||
// 读取结果会通过 BLEManager 已注册的 onBLECharacteristicValueChange 监听器返回
|
||||
await Taro.readBLECharacteristicValue({
|
||||
deviceId,
|
||||
serviceId: svc.uuid,
|
||||
characteristicId: charUUID,
|
||||
});
|
||||
} catch {
|
||||
// 某些特征值可能不支持读取
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
this.readings = [...this.readings, ...results];
|
||||
this.onReadings?.(results);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** 同步收集的读数到后端 */
|
||||
async syncToServer(uploadFn: (readings: NormalizedReading[]) => Promise<number>): Promise<SyncResult> {
|
||||
if (!this.connection) {
|
||||
return { success: false, readingsCount: 0, uploadedCount: 0, error: '未连接设备' };
|
||||
}
|
||||
|
||||
this.updateState('syncing');
|
||||
|
||||
const batch = this.readings.slice(-this.config.maxReadingsPerSync);
|
||||
if (batch.length === 0) {
|
||||
this.updateState('connected');
|
||||
return { success: true, readingsCount: 0, uploadedCount: 0 };
|
||||
}
|
||||
|
||||
let lastError: string | undefined;
|
||||
for (let attempt = 1; attempt <= this.config.retryCount; attempt++) {
|
||||
try {
|
||||
const uploaded = await uploadFn(batch);
|
||||
this.readings = this.readings.slice(batch.length);
|
||||
this.updateState('connected');
|
||||
return {
|
||||
success: true,
|
||||
readingsCount: batch.length,
|
||||
uploadedCount: uploaded,
|
||||
};
|
||||
} catch (e: any) {
|
||||
lastError = e.message || '上传失败';
|
||||
}
|
||||
}
|
||||
|
||||
this.updateState('error', lastError);
|
||||
return { success: false, readingsCount: batch.length, uploadedCount: 0, error: lastError };
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.connection) return;
|
||||
|
||||
const { deviceId } = this.connection;
|
||||
try {
|
||||
await Taro.closeBLEConnection({ deviceId });
|
||||
} catch {
|
||||
// 忽略断连错误
|
||||
}
|
||||
|
||||
this.connection = null;
|
||||
this.readings = [];
|
||||
this.updateState('disconnected');
|
||||
}
|
||||
|
||||
/** 关闭蓝牙适配器 */
|
||||
async destroy(): Promise<void> {
|
||||
await this.disconnect();
|
||||
try {
|
||||
await Taro.closeBluetoothAdapter();
|
||||
} catch {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前连接信息 */
|
||||
getConnection(): BLEConnection | null {
|
||||
return this.connection;
|
||||
}
|
||||
|
||||
/** 获取缓存的读数 */
|
||||
getCachedReadings(): NormalizedReading[] {
|
||||
return [...this.readings];
|
||||
}
|
||||
|
||||
/** 清除缓存读数 */
|
||||
clearReadings(): void {
|
||||
this.readings = [];
|
||||
}
|
||||
}
|
||||
|
||||
export default new BLEManager();
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { DeviceAdapter, NormalizedReading } from '../types';
|
||||
|
||||
/**
|
||||
* 小米手环 BLE 适配器
|
||||
*
|
||||
* 支持 Mi Band 7/8 等型号,使用标准 Heart Rate Service 读取心率数据。
|
||||
* 未来可扩展步数(0xFEE1)、睡眠等 Mi Band 专有 Service。
|
||||
*/
|
||||
|
||||
// 标准 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 Measurement 格式) */
|
||||
function parseHeartRate(data: ArrayBuffer): number | null {
|
||||
const view = new DataView(data);
|
||||
if (view.byteLength < 2) return null;
|
||||
|
||||
const flags = view.getUint8(0);
|
||||
// Bit 0: Heart Rate Format — 0 = UINT8, 1 = UINT16
|
||||
const isUINT16 = (flags & 0x01) !== 0;
|
||||
|
||||
if (isUINT16) {
|
||||
if (view.byteLength < 3) return null;
|
||||
return view.getUint16(1, true); // little-endian
|
||||
}
|
||||
return view.getUint8(1);
|
||||
}
|
||||
|
||||
export const XiaomiBandAdapter: DeviceAdapter = {
|
||||
name: 'Xiaomi Band',
|
||||
|
||||
supportedModels: [
|
||||
'Mi Band',
|
||||
'Mi Smart Band',
|
||||
'Xiaomi Band',
|
||||
'Xiaomi Smart Band',
|
||||
'MI BAND',
|
||||
'MiBand',
|
||||
],
|
||||
|
||||
serviceUUIDs: [HRS_SERVICE],
|
||||
|
||||
notifyCharacteristics: [
|
||||
{ service: HRS_SERVICE, characteristic: HRM_CHARACTERISTIC },
|
||||
],
|
||||
|
||||
readCharacteristics: [
|
||||
{ service: HRS_SERVICE, characteristic: HRM_CHARACTERISTIC },
|
||||
],
|
||||
|
||||
parseNotification(
|
||||
_serviceUUID: string,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading | null {
|
||||
if (charUUID.toUpperCase().includes('2A37')) {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
parseReadResponse(
|
||||
_serviceUUID: string,
|
||||
_charUUID: string,
|
||||
_data: ArrayBuffer,
|
||||
): NormalizedReading | null {
|
||||
// 读取模式暂不支持,使用通知模式获取数据
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
export default XiaomiBandAdapter;
|
||||
1
apps/miniprogram/src/services/ble/adapters/index.ts
Normal file
1
apps/miniprogram/src/services/ble/adapters/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
|
||||
11
apps/miniprogram/src/services/ble/index.ts
Normal file
11
apps/miniprogram/src/services/ble/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { BLEManager } from './BLEManager';
|
||||
export type {
|
||||
DeviceType,
|
||||
NormalizedReading,
|
||||
DeviceAdapter,
|
||||
BLEDevice,
|
||||
BLEConnection,
|
||||
BLEConnectionState,
|
||||
SyncResult,
|
||||
BLEManagerConfig,
|
||||
} from './types';
|
||||
89
apps/miniprogram/src/services/ble/types.ts
Normal file
89
apps/miniprogram/src/services/ble/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/** BLE 模块类型定义 */
|
||||
|
||||
/** 设备数据类型(与后端 device_readings.device_type 枚举对齐) */
|
||||
export type DeviceType =
|
||||
| 'heart_rate'
|
||||
| 'blood_oxygen'
|
||||
| 'steps'
|
||||
| 'sleep'
|
||||
| 'temperature'
|
||||
| 'stress';
|
||||
|
||||
/** 标准化的设备读数 */
|
||||
export interface NormalizedReading {
|
||||
device_type: DeviceType;
|
||||
values: Record<string, number>;
|
||||
measured_at: string;
|
||||
}
|
||||
|
||||
/** BLE 设备适配器接口 — 不同品牌设备实现此接口 */
|
||||
export interface DeviceAdapter {
|
||||
/** 适配器名称 */
|
||||
readonly name: string;
|
||||
|
||||
/** 支持的设备型号关键字(用于匹配广播名称) */
|
||||
readonly supportedModels: string[];
|
||||
|
||||
/** 需要订阅的 Service UUID 列表 */
|
||||
readonly serviceUUIDs: string[];
|
||||
|
||||
/** 需要监听的 Characteristic(用于通知模式) */
|
||||
readonly notifyCharacteristics: { service: string; characteristic: string }[];
|
||||
|
||||
/** 需要主动读取的 Characteristic */
|
||||
readonly readCharacteristics: { service: string; characteristic: string }[];
|
||||
|
||||
/** 解析 BLE 通知数据为标准读数 */
|
||||
parseNotification(
|
||||
serviceUUID: string,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading | null;
|
||||
|
||||
/** 解析 BLE 读取数据为标准读数 */
|
||||
parseReadResponse(
|
||||
serviceUUID: string,
|
||||
charUUID: string,
|
||||
data: ArrayBuffer,
|
||||
): NormalizedReading | null;
|
||||
}
|
||||
|
||||
/** 扫描发现的 BLE 设备 */
|
||||
export interface BLEDevice {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
RSSI: number;
|
||||
localName?: string;
|
||||
advertisData?: ArrayBuffer;
|
||||
adapter?: DeviceAdapter;
|
||||
}
|
||||
|
||||
/** BLE 连接状态 */
|
||||
export type BLEConnectionState = 'disconnected' | 'connecting' | 'connected' | 'syncing' | 'error';
|
||||
|
||||
/** BLE 连接信息 */
|
||||
export interface BLEConnection {
|
||||
deviceId: string;
|
||||
state: BLEConnectionState;
|
||||
adapter: DeviceAdapter;
|
||||
connectedAt?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 同步操作结果 */
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
readingsCount: number;
|
||||
uploadedCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** BLEManager 配置 */
|
||||
export interface BLEManagerConfig {
|
||||
/** 扫描超时(毫秒) */
|
||||
scanTimeout: number;
|
||||
/** 单次同步最大读数数量 */
|
||||
maxReadingsPerSync: number;
|
||||
/** 同步失败重试次数 */
|
||||
retryCount: number;
|
||||
}
|
||||
67
apps/miniprogram/src/services/device-sync.ts
Normal file
67
apps/miniprogram/src/services/device-sync.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { api } from './request';
|
||||
import type { NormalizedReading } from './ble/types';
|
||||
|
||||
interface BatchReadingRequest {
|
||||
device_id: string;
|
||||
device_model?: string;
|
||||
readings: {
|
||||
device_type: string;
|
||||
values: Record<string, number>;
|
||||
measured_at: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface BatchResult {
|
||||
accepted: number;
|
||||
duplicates: number;
|
||||
earliest: string | null;
|
||||
latest: string | null;
|
||||
}
|
||||
|
||||
/** 将标准化读数转换为后端批量请求格式并上传 */
|
||||
export async function uploadReadings(
|
||||
patientId: string,
|
||||
deviceId: string,
|
||||
deviceModel: string | undefined,
|
||||
readings: NormalizedReading[],
|
||||
): Promise<number> {
|
||||
if (readings.length === 0) return 0;
|
||||
|
||||
const body: BatchReadingRequest = {
|
||||
device_id: deviceId,
|
||||
device_model: deviceModel,
|
||||
readings: readings.map((r) => ({
|
||||
device_type: r.device_type,
|
||||
values: r.values,
|
||||
measured_at: r.measured_at,
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await api.post<BatchResult>(
|
||||
`/health/patients/${patientId}/device-readings/batch`,
|
||||
body,
|
||||
);
|
||||
return result.accepted;
|
||||
}
|
||||
|
||||
/** 查询设备原始数据 */
|
||||
export async function queryDeviceReadings(
|
||||
patientId: string,
|
||||
params?: { device_type?: string; hours?: number },
|
||||
) {
|
||||
return api.get<{ data: unknown[]; total: number }>(
|
||||
`/health/patients/${patientId}/device-readings`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询小时级降采样数据 */
|
||||
export async function queryHourlyReadings(
|
||||
patientId: string,
|
||||
params: { device_type: string; days?: number },
|
||||
) {
|
||||
return api.get<{ data: unknown[]; total: number }>(
|
||||
`/health/patients/${patientId}/device-readings/hourly`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user