From d715647a7363b2ec4400822f5fae979821bd534d Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 28 Apr 2026 19:30:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(mp):=20BloodPressureAdapter=20+=20GlucoseM?= =?UTF-8?q?eterAdapter=20=E2=80=94=20BLE=200x1810/0x1808=20=E6=A0=87?= =?UTF-8?q?=E5=87=86=E5=8D=8F=E8=AE=AE=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/device-sync/index.tsx | 17 ++- .../ble/adapters/BloodPressureAdapter.ts | 126 ++++++++++++++++ .../ble/adapters/GlucoseMeterAdapter.ts | 140 ++++++++++++++++++ .../src/services/ble/adapters/index.ts | 2 + 4 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 apps/miniprogram/src/services/ble/adapters/BloodPressureAdapter.ts create mode 100644 apps/miniprogram/src/services/ble/adapters/GlucoseMeterAdapter.ts diff --git a/apps/miniprogram/src/pages/device-sync/index.tsx b/apps/miniprogram/src/pages/device-sync/index.tsx index e34a4ab..8b9326d 100644 --- a/apps/miniprogram/src/pages/device-sync/index.tsx +++ b/apps/miniprogram/src/pages/device-sync/index.tsx @@ -3,6 +3,8 @@ import { View, Text } from '@tarojs/components'; import { useDidShow } from '@tarojs/taro'; import { BLEManager } from '@/services/ble/BLEManager'; 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 { useAuthStore } from '@/stores/auth'; import type { BLEDevice, NormalizedReading } from '@/services/ble/types'; @@ -10,6 +12,8 @@ import './index.scss'; const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 }); bleManager.registerAdapter(XiaomiBandAdapter); +bleManager.registerAdapter(BloodPressureAdapter); +bleManager.registerAdapter(GlucoseMeterAdapter); type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error'; @@ -41,7 +45,7 @@ export default function DeviceSync() { const found = await bleManager.scanDevices(); setDevices(found); if (found.length === 0) { - setErrorMsg('未发现支持的设备,请确认手环已开启蓝牙并靠近手机'); + setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机'); } setPageState('idle'); } catch (e: any) { @@ -106,7 +110,7 @@ export default function DeviceSync() { D 设备同步 - 连接智能手环,自动采集健康数据 + 连接智能手环、血压计、血糖仪,自动采集健康数据 @@ -147,12 +151,17 @@ export default function DeviceSync() { {liveReadings.slice(-5).reverse().map((r, i) => ( - {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} {r.device_type === 'heart_rate' ? `${r.values.heart_rate} bpm` - : JSON.stringify(r.values)} + : r.metric + ? `${r.values.value} ${r.values.unit}` + : JSON.stringify(r.values)} ))} diff --git a/apps/miniprogram/src/services/ble/adapters/BloodPressureAdapter.ts b/apps/miniprogram/src/services/ble/adapters/BloodPressureAdapter.ts new file mode 100644 index 0000000..af295c9 --- /dev/null +++ b/apps/miniprogram/src/services/ble/adapters/BloodPressureAdapter.ts @@ -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); + }, +}; diff --git a/apps/miniprogram/src/services/ble/adapters/GlucoseMeterAdapter.ts b/apps/miniprogram/src/services/ble/adapters/GlucoseMeterAdapter.ts new file mode 100644 index 0000000..96bd598 --- /dev/null +++ b/apps/miniprogram/src/services/ble/adapters/GlucoseMeterAdapter.ts @@ -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 = { + 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); + }, +}; diff --git a/apps/miniprogram/src/services/ble/adapters/index.ts b/apps/miniprogram/src/services/ble/adapters/index.ts index 33d3e5f..f47b279 100644 --- a/apps/miniprogram/src/services/ble/adapters/index.ts +++ b/apps/miniprogram/src/services/ble/adapters/index.ts @@ -1 +1,3 @@ export { XiaomiBandAdapter } from './XiaomiBandAdapter'; +export { BloodPressureAdapter } from './BloodPressureAdapter'; +export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';