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