feat(mp): BLE 血氧仪支持 + 服务发现增强

- 新增 Pulse Oximeter Service (0x1822) 支持,含 SFLOAT 解析
- 连接后自动扫描全部服务,发现并订阅已知健康 UUID
- 设备同步页展示已发现的服务和可用数据类型标签
- 新增 BLEDiscoveredService / BLEDiscoveredCharacteristic 类型
This commit is contained in:
iven
2026-05-25 13:43:16 +08:00
parent ef1b8eb348
commit a24c18155f
5 changed files with 307 additions and 15 deletions

View File

@@ -7,6 +7,8 @@ import type {
BLEConnectionChangeResult,
BLECharacteristicChangeResult,
BLEServiceItem,
BLEDiscoveredService,
BLEDiscoveredCharacteristic,
} from './types';
/** BLE 连接管理 — 封装连接/断开/服务发现/通知订阅/数据监听 */
@@ -121,10 +123,24 @@ export class BLEConnection {
}
}
/** 发现服务并启用通知 */
/** 已知的健康相关 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) =>
@@ -137,13 +153,90 @@ export class BLEConnection {
serviceId: svc.uuid,
});
await Taro.notifyBLECharacteristicValueChange({
deviceId: device.deviceId,
serviceId: svc.uuid,
characteristicId: charUUID,
state: true,
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(', '));
}
/** 手动读取特征值 */

View File

@@ -23,8 +23,44 @@ const SERVICES: Record<string, { uuid: string; chars: { notify: string; read: st
read: '00002A35-0000-1000-8000-00805F9B34FB',
},
},
pulse_oximeter: {
uuid: '00001822-0000-1000-8000-00805F9B34FB',
chars: {
// PLX Continuous Measurement — 实时血氧+脉率
notify: '00002A5F-0000-1000-8000-00805F9B34FB',
// PLX Spot-Check Measurement — 单次测量
read: '00002A5E-0000-1000-8000-00805F9B34FB',
},
},
};
// ── IEEE 11073 SFLOAT 解析Bluetooth SIG 医疗 Profile 通用格式) ──
/** 特殊 SFLOAT 值 */
const SFLOAT_NAN = 0x07FF;
const SFLOAT_NRES = 0x0800;
const SFLOAT_POS_INF = 0x07FE;
const SFLOAT_NEG_INF = 0x0802;
function parseSFLOAT(view: DataView, offset: number): number | null {
if (offset + 2 > view.byteLength) return null;
const raw = view.getUint16(offset, true);
if (raw === SFLOAT_NAN || raw === SFLOAT_NRES) return null;
if (raw === SFLOAT_POS_INF) return Infinity;
if (raw === SFLOAT_NEG_INF) return -Infinity;
const signM = (raw >> 15) & 0x01;
const exp = (raw >> 12) & 0x07;
const mantissa = raw & 0x0FFF;
// 指数用 3 位补码表示0-3 正4-7 负)
const exponent = exp >= 4 ? exp - 8 : exp;
const signedMantissa = signM ? -(mantissa ^ 0x0FFF) - 1 : mantissa;
return signedMantissa * Math.pow(10, exponent);
}
// ── 解析器 ──
function parseHeartRate(data: ArrayBuffer): NormalizedReading | null {
@@ -66,6 +102,39 @@ function parseTemperature(data: ArrayBuffer): NormalizedReading | null {
};
}
/**
* 解析 Pulse Oximeter Service 数据
* PLX Continuous Measurement (0x2A5F) 和 Spot-Check (0x2A5E) 共用
* 格式: Flags(1B) + SpO2(SFLOAT 2B) + PulseRate(SFLOAT 2B) + optional...
*/
function parsePulseOximeter(data: ArrayBuffer): NormalizedReading[] {
const view = new DataView(data);
if (view.byteLength < 5) return [];
const spO2 = parseSFLOAT(view, 1);
const pulseRate = parseSFLOAT(view, 3);
const now = new Date().toISOString();
const results: NormalizedReading[] = [];
if (spO2 !== null && spO2 >= 0 && spO2 <= 100) {
results.push({
device_type: 'blood_oxygen',
values: { blood_oxygen: Math.round(spO2), unit: '%' },
measured_at: now,
});
}
if (pulseRate !== null && pulseRate > 0 && pulseRate <= 300) {
results.push({
device_type: 'heart_rate',
values: { heart_rate: Math.round(pulseRate) },
measured_at: now,
});
}
return results;
}
// ── 工厂函数 ──
export interface GenericAdapterConfig {
@@ -100,20 +169,23 @@ export function createGenericBleAdapter(config: GenericAdapterConfig): DeviceAda
): NormalizedReading[] {
const upper = charUUID.toUpperCase();
// Heart Rate Measurement
const hrsChar = SERVICES.heart_rate.chars.notify.toUpperCase();
if (upper === hrsChar || upper.includes('2A37')) {
// Heart Rate Measurement (0x2A37)
if (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')) {
// Temperature Measurement (0x2A1C)
if (upper.includes('2A1C')) {
const result = parseTemperature(data);
return result ? [result] : [];
}
// Pulse Oximeter Continuous (0x2A5F) / Spot-Check (0x2A5E)
if (upper.includes('2A5F') || upper.includes('2A5E')) {
return parsePulseOximeter(data);
}
return [];
},
@@ -155,7 +227,7 @@ export const HuaweiBandAdapter = createGenericBleAdapter({
'华为手环',
'华为手表',
],
profiles: ['heart_rate', 'health_thermometer'],
profiles: ['heart_rate', 'health_thermometer', 'pulse_oximeter'],
});
/**

View File

@@ -72,6 +72,8 @@ export interface BLEConnection {
adapter: DeviceAdapter;
connectedAt?: number;
error?: string;
/** 连接后扫描到的全部服务(用于调试和展示) */
discoveredServices?: BLEDiscoveredService[];
}
/** 同步操作结果 */
@@ -96,7 +98,20 @@ export interface BLEManagerConfig {
export type GenericBLEProfile =
| 'heart_rate' // Heart Rate Service (0x180D)
| 'health_thermometer' // Health Thermometer Service (0x1809)
| 'blood_pressure'; // Blood Pressure Service (0x1810)
| 'blood_pressure' // Blood Pressure Service (0x1810)
| 'pulse_oximeter'; // Pulse Oximeter Service (0x1822)
/** BLE 服务发现结果(连接后扫描到的全部服务/特征) */
export interface BLEDiscoveredCharacteristic {
uuid: string;
properties: { read: boolean; write: boolean; notify: boolean; indicate: boolean };
}
export interface BLEDiscoveredService {
uuid: string;
isPrimary: boolean;
characteristics: BLEDiscoveredCharacteristic[];
}
/** 微信 BLE 扫描回调结果 */
export interface BLEScanResult {