feat(mp): BloodPressureAdapter + GlucoseMeterAdapter — BLE 0x1810/0x1808 标准协议适配器
This commit is contained in:
@@ -3,6 +3,8 @@ import { View, Text } from '@tarojs/components';
|
|||||||
import { useDidShow } from '@tarojs/taro';
|
import { useDidShow } from '@tarojs/taro';
|
||||||
import { BLEManager } from '@/services/ble/BLEManager';
|
import { BLEManager } from '@/services/ble/BLEManager';
|
||||||
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter';
|
||||||
|
import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter';
|
||||||
|
import { GlucoseMeterAdapter } from '@/services/ble/adapters/GlucoseMeterAdapter';
|
||||||
import { uploadReadings } from '@/services/device-sync';
|
import { uploadReadings } from '@/services/device-sync';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
|
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
|
||||||
@@ -10,6 +12,8 @@ import './index.scss';
|
|||||||
|
|
||||||
const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
|
const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 });
|
||||||
bleManager.registerAdapter(XiaomiBandAdapter);
|
bleManager.registerAdapter(XiaomiBandAdapter);
|
||||||
|
bleManager.registerAdapter(BloodPressureAdapter);
|
||||||
|
bleManager.registerAdapter(GlucoseMeterAdapter);
|
||||||
|
|
||||||
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error';
|
||||||
|
|
||||||
@@ -41,7 +45,7 @@ export default function DeviceSync() {
|
|||||||
const found = await bleManager.scanDevices();
|
const found = await bleManager.scanDevices();
|
||||||
setDevices(found);
|
setDevices(found);
|
||||||
if (found.length === 0) {
|
if (found.length === 0) {
|
||||||
setErrorMsg('未发现支持的设备,请确认手环已开启蓝牙并靠近手机');
|
setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机');
|
||||||
}
|
}
|
||||||
setPageState('idle');
|
setPageState('idle');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -106,7 +110,7 @@ export default function DeviceSync() {
|
|||||||
<View className="sync-hero">
|
<View className="sync-hero">
|
||||||
<Text className="sync-hero-icon">D</Text>
|
<Text className="sync-hero-icon">D</Text>
|
||||||
<Text className="sync-hero-title">设备同步</Text>
|
<Text className="sync-hero-title">设备同步</Text>
|
||||||
<Text className="sync-hero-desc">连接智能手环,自动采集健康数据</Text>
|
<Text className="sync-hero-desc">连接智能手环、血压计、血糖仪,自动采集健康数据</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="sync-action" onClick={handleScan}>
|
<View className="sync-action" onClick={handleScan}>
|
||||||
@@ -147,12 +151,17 @@ export default function DeviceSync() {
|
|||||||
{liveReadings.slice(-5).reverse().map((r, i) => (
|
{liveReadings.slice(-5).reverse().map((r, i) => (
|
||||||
<View key={i} className="sync-reading-item">
|
<View key={i} className="sync-reading-item">
|
||||||
<Text className="sync-reading-type">
|
<Text className="sync-reading-type">
|
||||||
{r.device_type === 'heart_rate' ? '心率' : r.device_type}
|
{r.device_type === 'heart_rate' ? '心率'
|
||||||
|
: r.device_type === 'blood_pressure' ? `血压(${r.metric === 'systolic' ? '收缩压' : r.metric === 'diastolic' ? '舒张压' : 'MAP'})`
|
||||||
|
: r.device_type === 'blood_glucose' ? '血糖'
|
||||||
|
: r.device_type}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="sync-reading-value">
|
<Text className="sync-reading-value">
|
||||||
{r.device_type === 'heart_rate'
|
{r.device_type === 'heart_rate'
|
||||||
? `${r.values.heart_rate} bpm`
|
? `${r.values.heart_rate} bpm`
|
||||||
: JSON.stringify(r.values)}
|
: r.metric
|
||||||
|
? `${r.values.value} ${r.values.unit}`
|
||||||
|
: JSON.stringify(r.values)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1 +1,3 @@
|
|||||||
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
|
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
|
||||||
|
export { BloodPressureAdapter } from './BloodPressureAdapter';
|
||||||
|
export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';
|
||||||
|
|||||||
Reference in New Issue
Block a user