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();
|
||||
Reference in New Issue
Block a user