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

244 lines
6.8 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 type { DeviceAdapter, NormalizedReading, GenericBLEProfile } from '../types';
// ── Bluetooth SIG 标准 Service UUID ──
const SERVICES: Record<string, { uuid: string; chars: { notify: string; read: string } }> = {
heart_rate: {
uuid: '0000180D-0000-1000-8000-00805F9B34FB',
chars: {
notify: '00002A37-0000-1000-8000-00805F9B34FB',
read: '00002A37-0000-1000-8000-00805F9B34FB',
},
},
health_thermometer: {
uuid: '00001809-0000-1000-8000-00805F9B34FB',
chars: {
notify: '00002A1C-0000-1000-8000-00805F9B34FB',
read: '00002A1C-0000-1000-8000-00805F9B34FB',
},
},
blood_pressure: {
uuid: '00001810-0000-1000-8000-00805F9B34FB',
chars: {
notify: '00002A35-0000-1000-8000-00805F9B34FB',
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 {
const view = new DataView(data);
if (view.byteLength < 2) return null;
const flags = view.getUint8(0);
const isUINT16 = (flags & 0x01) !== 0;
const hr = isUINT16
? (view.byteLength >= 3 ? view.getUint16(1, true) : null)
: view.getUint8(1);
if (hr === null || hr <= 0 || hr > 300) return null;
return {
device_type: 'heart_rate',
values: { heart_rate: hr },
measured_at: new Date().toISOString(),
};
}
function parseTemperature(data: ArrayBuffer): NormalizedReading | null {
const view = new DataView(data);
if (view.byteLength < 4) return null;
const flags = view.getUint8(0);
const isFahrenheit = (flags & 0x01) !== 0;
const mantissa = view.getInt16(1, true);
const exponent = view.getInt8(3);
const temp = mantissa * Math.pow(10, exponent);
const tempCelsius = isFahrenheit ? (temp - 32) * 5 / 9 : temp;
return {
device_type: 'temperature',
values: { value: Math.round(tempCelsius * 10) / 10, unit: '°C' },
measured_at: new Date().toISOString(),
};
}
/**
* 解析 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 {
name: string;
supportedModels: string[];
profiles: GenericBLEProfile[];
}
/** 创建通用 BLE 适配器 — 基于 Bluetooth SIG 标准 Health Profile */
export function createGenericBleAdapter(config: GenericAdapterConfig): DeviceAdapter {
const activeServices = config.profiles
.map((p) => SERVICES[p])
.filter(Boolean);
return {
name: config.name,
supportedModels: config.supportedModels,
serviceUUIDs: activeServices.map((s) => s.uuid),
notifyCharacteristics: activeServices.map((s) => ({
service: s.uuid,
characteristic: s.chars.notify,
})),
readCharacteristics: activeServices.map((s) => ({
service: s.uuid,
characteristic: s.chars.read,
})),
parseNotification(
_serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
const upper = charUUID.toUpperCase();
// Heart Rate Measurement (0x2A37)
if (upper.includes('2A37')) {
const result = parseHeartRate(data);
return result ? [result] : [];
}
// 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 [];
},
parseReadResponse(
serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
return this.parseNotification(serviceUUID, charUUID, data);
},
};
}
/**
* 预配置:通用定制手环(心率 + 体温)
* 未来定制手环只需在 supportedModels 中加入型号名
*/
export const CustomBandAdapter = createGenericBleAdapter({
name: 'Custom Health Band',
supportedModels: [
'Health Band',
'Medical Band',
'Smart Bracelet',
'健康手环',
],
profiles: ['heart_rate', 'health_thermometer'],
});
/** 华为手环/手表 BLE 适配器 */
export const HuaweiBandAdapter = createGenericBleAdapter({
name: 'Huawei Band',
supportedModels: [
'HUAWEI Band',
'HUAWEI Watch',
'Huawei Band',
'Huawei Watch',
'HW-B',
'HUAW',
'华为手环',
'华为手表',
],
profiles: ['heart_rate', 'health_thermometer', 'pulse_oximeter'],
});
/**
* 万能 fallback 适配器 — 匹配所有有名称的设备
* 尝试标准 BLE 健康协议(心率/体温/血压),设备不支持的服务会被安全跳过
*/
export const FallbackAdapter = createGenericBleAdapter({
name: '通用设备',
supportedModels: [], // 不参与 matchAdapter仅作为 fallback
profiles: ['heart_rate', 'health_thermometer', 'blood_pressure'],
});
export default CustomBandAdapter;