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';