- 新增 Pulse Oximeter Service (0x1822) 支持,含 SFLOAT 解析 - 连接后自动扫描全部服务,发现并订阅已知健康 UUID - 设备同步页展示已发现的服务和可用数据类型标签 - 新增 BLEDiscoveredService / BLEDiscoveredCharacteristic 类型
306 lines
9.9 KiB
TypeScript
306 lines
9.9 KiB
TypeScript
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');
|
||
}
|
||
}
|