Files
hms/apps/miniprogram/src/services/ble/BLEConnection.ts
iven a24c18155f feat(mp): BLE 血氧仪支持 + 服务发现增强
- 新增 Pulse Oximeter Service (0x1822) 支持,含 SFLOAT 解析
- 连接后自动扫描全部服务,发现并订阅已知健康 UUID
- 设备同步页展示已发现的服务和可用数据类型标签
- 新增 BLEDiscoveredService / BLEDiscoveredCharacteristic 类型
2026-05-25 13:43:16 +08:00

306 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Taro from '@tarojs/taro';
import type {
BLEDevice,
BLEConnection as BLEConnectionInfo,
BLEConnectionState,
NormalizedReading,
BLEConnectionChangeResult,
BLECharacteristicChangeResult,
BLEServiceItem,
BLEDiscoveredService,
BLEDiscoveredCharacteristic,
} from './types';
/** BLE 连接管理 — 封装连接/断开/服务发现/通知订阅/数据监听 */
export class BLEConnection {
private conn: BLEConnectionInfo | null = null;
private readings: NormalizedReading[] = [];
private connChangeHandler: ((res: BLEConnectionChangeResult) => void) | null = null;
private charChangeHandler: ((res: BLECharacteristicChangeResult) => void) | null = null;
private onStateChange: (state: BLEConnectionState, error?: string) => void;
private onNewReadings: (readings: NormalizedReading[]) => void;
constructor(callbacks: {
onStateChange: (state: BLEConnectionState, error?: string) => void;
onNewReadings: (readings: NormalizedReading[]) => void;
}) {
this.onStateChange = callbacks.onStateChange;
this.onNewReadings = callbacks.onNewReadings;
}
/** 获取当前连接信息 */
getConnection(): BLEConnectionInfo | null {
return this.conn;
}
/** 获取缓存的读数 */
getCachedReadings(): NormalizedReading[] {
return [...this.readings];
}
/** 清除缓存读数 */
clearReadings(): void {
this.readings = [];
}
/** 设置缓存读数(供外部 BLEManager 使用) */
setReadings(readings: NormalizedReading[]): void {
this.readings = readings;
}
private updateState(state: BLEConnectionState, error?: string): void {
if (this.conn) {
this.conn = { ...this.conn, state, error };
}
this.onStateChange(state, error);
}
/** 连接到设备 */
async connect(device: BLEDevice, maxLiveReadings: number): Promise<void> {
if (!device.adapter) throw new Error('设备无适配器');
this.conn = {
deviceId: device.deviceId,
state: 'connecting',
adapter: device.adapter,
};
this.updateState('connecting');
this.readings = [];
try {
await Taro.createBLEConnection({
deviceId: device.deviceId,
timeout: 10000,
});
// 移除旧监听器,避免多次 connect 累积
if (this.connChangeHandler) {
Taro.offBLEConnectionStateChange(this.connChangeHandler);
}
if (this.charChangeHandler) {
Taro.offBLECharacteristicValueChange(this.charChangeHandler);
}
// 监听断连
this.connChangeHandler = (res: BLEConnectionChangeResult) => {
if (res.deviceId === device.deviceId && !res.connected) {
this.updateState('disconnected', '设备断开连接');
this.conn = null;
}
};
Taro.onBLEConnectionStateChange(this.connChangeHandler);
// 发现服务
await this.discoverServices(device);
// 监听数据通知
this.charChangeHandler = (res: BLECharacteristicChangeResult) => {
if (res.deviceId !== device.deviceId) return;
const newReadings = device.adapter!.parseNotification(
res.serviceId,
res.characteristicId,
res.value,
);
if (newReadings.length > 0) {
const combined = [...this.readings, ...newReadings];
this.readings = combined.length > maxLiveReadings
? combined.slice(-maxLiveReadings)
: combined;
this.onNewReadings(newReadings);
}
};
Taro.onBLECharacteristicValueChange(this.charChangeHandler);
this.conn = { ...this.conn, state: 'connected', connectedAt: Date.now() };
this.updateState('connected');
} catch (e: unknown) {
const errMsg = (e as { errMsg?: string })?.errMsg;
const msg = errMsg || (e instanceof Error ? e.message : '') || '连接失败';
this.updateState('error', msg);
this.conn = null;
throw new Error(errMsg || '蓝牙连接失败');
}
}
/** 已知的健康相关 Characteristic UUID用于自动发现和订阅 */
private static readonly HEALTH_CHAR_UUIDS: Record<string, string> = {
'2A37': 'heart_rate', // Heart Rate Measurement
'2A38': 'heart_rate_loc', // Body Sensor Location
'2A1C': 'temperature', // Temperature Measurement
'2A35': 'blood_pressure', // Blood Pressure Measurement
'2A5F': 'blood_oxygen', // PLX Continuous Measurement
'2A5E': 'blood_oxygen_spot',// PLX Spot-Check Measurement
};
/** 发现服务并启用通知 — 先订阅适配器指定的,再扫描全部服务尝试自动发现 */
private async discoverServices(device: BLEDevice): Promise<void> {
const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId });
const services = servicesRes.services || [];
const discoveredServices: BLEDiscoveredService[] = [];
// ── 第一轮:订阅适配器预定义的 Characteristic保持向后兼容 ──
const subscribedCharUUIDs = new Set<string>();
for (const { service: svcUUID, characteristic: charUUID } of device.adapter!.notifyCharacteristics) {
const svc = services.find((s: BLEServiceItem) =>
s.uuid.toUpperCase().includes(svcUUID.toUpperCase()),
);
if (!svc) continue;
await Taro.getBLEDeviceCharacteristics({
deviceId: device.deviceId,
serviceId: svc.uuid,
});
try {
await Taro.notifyBLECharacteristicValueChange({
deviceId: device.deviceId,
serviceId: svc.uuid,
characteristicId: charUUID,
state: true,
});
subscribedCharUUIDs.add(charUUID.toUpperCase().replace(/-/g, '').slice(-4));
console.log(`[ble] 已订阅适配器预定义: ${svcUUID} / ${charUUID}`);
} catch (err) {
console.warn(`[ble] 订阅失败 (预定义): ${charUUID}`, err);
}
}
// ── 第二轮:扫描全部服务,发现并订阅健康相关 Characteristic ──
for (const svc of services) {
const svcUUID = svc.uuid.toUpperCase();
const discoveredChars: BLEDiscoveredCharacteristic[] = [];
let charsRes: Taro.getBLEDeviceCharacteristics.SuccessCallbackResult;
try {
charsRes = await Taro.getBLEDeviceCharacteristics({
deviceId: device.deviceId,
serviceId: svc.uuid,
});
} catch (err) {
console.warn(`[ble] 读取特征列表失败: ${svcUUID}`, err);
continue;
}
const characteristics = charsRes.characteristics || [];
for (const char of characteristics) {
const charUUIDShort = char.uuid.toUpperCase().replace(/-/g, '').slice(-4);
const props = char.properties || {};
const discoveredChar: BLEDiscoveredCharacteristic = {
uuid: char.uuid,
properties: {
read: !!props.read,
write: !!props.write,
notify: !!props.notify,
indicate: !!props.indicate,
},
};
discoveredChars.push(discoveredChar);
// 如果是已知的健康 UUID 且尚未订阅,尝试订阅
if (
BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort] &&
!subscribedCharUUIDs.has(charUUIDShort) &&
(props.notify || props.indicate)
) {
try {
await Taro.notifyBLECharacteristicValueChange({
deviceId: device.deviceId,
serviceId: svc.uuid,
characteristicId: char.uuid,
state: true,
});
subscribedCharUUIDs.add(charUUIDShort);
console.log(`[ble] 自动发现并订阅: ${BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort]} (${svcUUID} / ${char.uuid})`);
} catch (err) {
console.warn(`[ble] 自动订阅失败: ${char.uuid}`, err);
}
}
}
discoveredServices.push({
uuid: svc.uuid,
isPrimary: !!svc.isPrimary,
characteristics: discoveredChars,
});
}
// 存储发现结果到连接信息
if (this.conn) {
this.conn = { ...this.conn, discoveredServices };
}
console.log(`[ble] 服务发现完成: ${discoveredServices.length} 个服务, 已订阅 ${subscribedCharUUIDs.size} 个特征`);
console.log(`[ble] 已订阅的健康特征:`, [...subscribedCharUUIDs].map(
(s) => `${s}(${BLEConnection.HEALTH_CHAR_UUIDS[s] ?? '未知'})`
).join(', '));
}
/** 手动读取特征值 */
async readCharacteristics(): Promise<NormalizedReading[]> {
if (!this.conn || this.conn.state !== 'connected') {
throw new Error('设备未连接');
}
const { deviceId, adapter } = this.conn;
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: BLEServiceItem) =>
s.uuid.toUpperCase().includes(svcUUID.toUpperCase()),
);
if (!svc) continue;
try {
await Taro.readBLECharacteristicValue({
deviceId,
serviceId: svc.uuid,
characteristicId: charUUID,
});
} catch (err) {
console.warn('[ble] 读取特征值失败:', err);
}
}
if (results.length > 0) {
this.readings = [...this.readings, ...results];
this.onNewReadings(results);
}
return results;
}
/** 断开连接 */
async disconnect(): Promise<void> {
if (!this.conn) return;
const { deviceId } = this.conn;
// 移除 BLE 监听器,防止断开后仍收到回调
if (this.connChangeHandler) {
Taro.offBLEConnectionStateChange(this.connChangeHandler);
this.connChangeHandler = null;
}
if (this.charChangeHandler) {
Taro.offBLECharacteristicValueChange(this.charChangeHandler);
this.charChangeHandler = null;
}
try {
await Taro.closeBLEConnection({ deviceId });
} catch {
// 忽略断连错误
}
this.conn = null;
this.readings = [];
this.updateState('disconnected');
}
}