feat(mp): BloodPressureAdapter + GlucoseMeterAdapter — BLE 0x1810/0x1808 标准协议适配器

This commit is contained in:
iven
2026-04-28 19:30:03 +08:00
parent e7b2e6382a
commit d715647a73
4 changed files with 281 additions and 4 deletions

View File

@@ -0,0 +1,126 @@
import type { DeviceAdapter, NormalizedReading } from '../types';
// Bluetooth SIG Blood Pressure Service
const BPS_SERVICE = '00001810-0000-1000-8000-00805f9b34fb';
const BPM_CHARACTERISTIC = '00002A35-0000-1000-8000-00805f9b34fb';
interface BPMData {
systolic: number;
diastolic: number;
map: number;
unit: string;
pulseRate?: number;
}
// SFLOAT (IEEE 11073 16-bit float): mantissa bits 0-11, exponent bits 12-15
function readSfloat(view: DataView, byteOffset: number): number {
const raw = view.getUint16(byteOffset, true);
const mantissa = raw & 0x0FFF;
const exponent = (raw >> 12) & 0x0F;
const signedMantissa = mantissa > 0x07FF ? mantissa - 0x1000 : mantissa;
const signedExponent = exponent > 0x07 ? exponent - 0x10 : exponent;
return signedMantissa * Math.pow(10, signedExponent);
}
function parseBloodPressureMeasurement(data: ArrayBuffer): BPMData | null {
const view = new DataView(data);
if (view.byteLength < 7) return null;
let offset = 0;
const flags = view.getUint8(offset);
offset += 1;
const systolic = readSfloat(view, offset);
offset += 2;
const diastolic = readSfloat(view, offset);
offset += 2;
const map = readSfloat(view, offset);
offset += 2;
let pulseRate: number | undefined;
if (flags & 0x01) offset += 7; // timestamp
if (flags & 0x02) {
pulseRate = readSfloat(view, offset);
}
return {
systolic: Math.round(systolic * 10) / 10,
diastolic: Math.round(diastolic * 10) / 10,
map: Math.round(map * 10) / 10,
unit: 'mmHg',
pulseRate,
};
}
export const BloodPressureAdapter: DeviceAdapter = {
name: 'Blood Pressure Monitor',
supportedModels: [
'AND UA-651BLE',
'Omron HEM-7322',
'Omron BLE',
'BP Monitor',
'A&D BLE',
'iHealth BP',
'Beurer BM',
'Yuwell BLE',
],
serviceUUIDs: [BPS_SERVICE],
notifyCharacteristics: [
{ service: BPS_SERVICE, characteristic: BPM_CHARACTERISTIC },
],
readCharacteristics: [
{ service: BPS_SERVICE, characteristic: BPM_CHARACTERISTIC },
],
parseNotification(
_serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
if (charUUID.toUpperCase() !== BPM_CHARACTERISTIC.toUpperCase()) return [];
const parsed = parseBloodPressureMeasurement(data);
if (!parsed) return [];
const measuredAt = new Date().toISOString();
const readings: NormalizedReading[] = [
{
device_type: 'blood_pressure',
metric: 'systolic',
values: { value: parsed.systolic, unit: parsed.unit },
measured_at: measuredAt,
},
{
device_type: 'blood_pressure',
metric: 'diastolic',
values: { value: parsed.diastolic, unit: parsed.unit },
measured_at: measuredAt,
},
{
device_type: 'blood_pressure',
metric: 'map',
values: { value: parsed.map, unit: parsed.unit },
measured_at: measuredAt,
},
];
if (parsed.pulseRate != null) {
readings.push({
device_type: 'heart_rate',
metric: 'pulse_rate',
values: { value: parsed.pulseRate, unit: 'bpm' },
measured_at: measuredAt,
});
}
return readings;
},
parseReadResponse(
serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
return this.parseNotification(serviceUUID, charUUID, data);
},
};

View File

@@ -0,0 +1,140 @@
import type { DeviceAdapter, NormalizedReading } from '../types';
// Bluetooth SIG Glucose Service
const GLS_SERVICE = '00001808-0000-1000-8000-00805f9b34fb';
const GLM_CHARACTERISTIC = '00002A18-0000-1000-8000-00805f9b34fb';
type GlucoseType =
| 'capillary_whole'
| 'capillary_plasma'
| 'venous_whole'
| 'venous_plasma'
| 'arterial_whole'
| 'arterial_plasma'
| 'undetermined'
| 'control';
const GLUCOSE_TYPE_LABELS: Record<number, GlucoseType> = {
1: 'capillary_whole',
2: 'capillary_plasma',
3: 'venous_whole',
4: 'venous_plasma',
5: 'arterial_whole',
6: 'arterial_plasma',
7: 'undetermined',
8: 'control',
};
// SFLOAT (IEEE 11073 16-bit float)
function readSfloat(view: DataView, byteOffset: number): number {
const raw = view.getUint16(byteOffset, true);
const mantissa = raw & 0x0FFF;
const exponent = (raw >> 12) & 0x0F;
const signedMantissa = mantissa > 0x07FF ? mantissa - 0x1000 : mantissa;
const signedExponent = exponent > 0x07 ? exponent - 0x10 : exponent;
return signedMantissa * Math.pow(10, signedExponent);
}
interface GlucoseMeasurement {
concentration: number;
unit: string;
type: GlucoseType;
timestamp: Date;
sequenceNumber: number;
}
function parseGlucoseMeasurement(data: ArrayBuffer): GlucoseMeasurement | null {
const view = new DataView(data);
if (view.byteLength < 3) return null;
let offset = 0;
const flags = view.getUint8(offset);
offset += 1;
const sequenceNumber = view.getUint16(offset, true);
offset += 2;
const year = view.getUint16(offset, true);
offset += 2;
const month = view.getUint8(offset);
offset += 1;
const day = view.getUint8(offset);
offset += 1;
const hours = view.getUint8(offset);
offset += 1;
const minutes = view.getUint8(offset);
offset += 1;
const seconds = view.getUint8(offset);
offset += 1;
const timestamp = new Date(year, month - 1, day, hours, minutes, seconds);
if (flags & 0x01) offset += 2; // time offset
const concentration = readSfloat(view, offset);
offset += 2;
const typeAndLocation = view.getUint8(offset);
const glucoseType: GlucoseType =
GLUCOSE_TYPE_LABELS[(typeAndLocation >> 4) & 0x0F] || 'undetermined';
// kg/L → mmol/L (分子量 ~180.16 g/mol, 1 kg/L = 5546 mmol/L)
const concentrationMmol = Math.round(concentration * 5546 * 10) / 10;
return {
concentration: concentrationMmol,
unit: 'mmol/L',
type: glucoseType,
timestamp,
sequenceNumber,
};
}
export const GlucoseMeterAdapter: DeviceAdapter = {
name: 'Glucose Meter',
supportedModels: [
'Accu-Chek',
'Contour',
'OneTouch',
'FreeStyle Libre',
'Glucose Meter',
'iHealth BG',
'Yuwell GLU',
'Bionime',
'Sinocare',
],
serviceUUIDs: [GLS_SERVICE],
notifyCharacteristics: [
{ service: GLS_SERVICE, characteristic: GLM_CHARACTERISTIC },
],
readCharacteristics: [
{ service: GLS_SERVICE, characteristic: GLM_CHARACTERISTIC },
],
parseNotification(
_serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
if (charUUID.toUpperCase() !== GLM_CHARACTERISTIC.toUpperCase()) return [];
const parsed = parseGlucoseMeasurement(data);
if (!parsed) return [];
return [
{
device_type: 'blood_glucose',
metric: parsed.type,
values: { value: parsed.concentration, unit: parsed.unit },
measured_at: parsed.timestamp.toISOString(),
},
];
},
parseReadResponse(
serviceUUID: string,
charUUID: string,
data: ArrayBuffer,
): NormalizedReading[] {
return this.parseNotification(serviceUUID, charUUID, data);
},
};

View File

@@ -1 +1,3 @@
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
export { BloodPressureAdapter } from './BloodPressureAdapter';
export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';