From 215fb35e0e12266b6e55e81e55911020d3d67a07 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 27 Apr 2026 07:53:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(miniprogram):=20BLE=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=A8=A1=E5=9D=97=20=E2=80=94=20=E6=89=AB?= =?UTF-8?q?=E6=8F=8F+=E8=BF=9E=E6=8E=A5+=E6=95=B0=E6=8D=AE=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Task 18: BLE 类型定义(NormalizedReading/DeviceAdapter/BLEDevice)+ BLEManager 连接管理器 - Task 19: XiaomiBandAdapter 心率读取适配器(标准 HRS Service 0x180D) - Task 20: device-sync API 层 + 设备同步页面 + app.config 路由注册 --- apps/miniprogram/src/app.config.ts | 1 + .../src/pages/device-sync/index.scss | 235 +++++++++++++ .../src/pages/device-sync/index.tsx | 216 ++++++++++++ .../src/services/ble/BLEManager.ts | 308 ++++++++++++++++++ .../ble/adapters/XiaomiBandAdapter.ts | 83 +++++ .../src/services/ble/adapters/index.ts | 1 + apps/miniprogram/src/services/ble/index.ts | 11 + apps/miniprogram/src/services/ble/types.ts | 89 +++++ apps/miniprogram/src/services/device-sync.ts | 67 ++++ 9 files changed, 1011 insertions(+) create mode 100644 apps/miniprogram/src/pages/device-sync/index.scss create mode 100644 apps/miniprogram/src/pages/device-sync/index.tsx create mode 100644 apps/miniprogram/src/services/ble/BLEManager.ts create mode 100644 apps/miniprogram/src/services/ble/adapters/XiaomiBandAdapter.ts create mode 100644 apps/miniprogram/src/services/ble/adapters/index.ts create mode 100644 apps/miniprogram/src/services/ble/index.ts create mode 100644 apps/miniprogram/src/services/ble/types.ts create mode 100644 apps/miniprogram/src/services/device-sync.ts diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index c6b200d..d662a0d 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -40,6 +40,7 @@ export default defineAppConfig({ 'pages/doctor/report/index', 'pages/doctor/report/detail/index', 'pages/events/index', + 'pages/device-sync/index', ], tabBar: { color: '#94A3B8', diff --git a/apps/miniprogram/src/pages/device-sync/index.scss b/apps/miniprogram/src/pages/device-sync/index.scss new file mode 100644 index 0000000..215e656 --- /dev/null +++ b/apps/miniprogram/src/pages/device-sync/index.scss @@ -0,0 +1,235 @@ +.device-sync-page { + min-height: 100vh; + background: #F1F5F9; + padding-bottom: env(safe-area-inset-bottom); +} + +.sync-header { + background: linear-gradient(135deg, #0891B2, #0E7490); + padding: 48px 24px 24px; + color: #fff; +} + +.sync-header-title { + font-size: 20px; + font-weight: 600; +} + +.sync-section { + padding: 16px; +} + +.sync-hero { + display: flex; + flex-direction: column; + align-items: center; + padding: 32px 16px; + background: #fff; + border-radius: 12px; + margin-bottom: 16px; +} + +.sync-hero-icon { + font-size: 48px; + margin-bottom: 12px; +} + +.sync-hero-title { + font-size: 18px; + font-weight: 600; + color: #1E293B; + margin-bottom: 4px; +} + +.sync-hero-desc { + font-size: 13px; + color: #64748B; +} + +.sync-action { + display: flex; + align-items: center; + justify-content: center; + background: #0891B2; + border-radius: 8px; + padding: 12px 24px; + margin: 8px 0; +} + +.sync-action--primary { + flex: 1; + background: #0891B2; +} + +.sync-action--danger { + flex: 1; + background: #EF4444; + margin-left: 12px; +} + +.sync-action-text { + color: #fff; + font-size: 15px; + font-weight: 500; +} + +.sync-device-list { + margin-top: 12px; +} + +.sync-section-title { + font-size: 14px; + font-weight: 600; + color: #475569; + margin-bottom: 8px; + display: block; +} + +.sync-device-item { + display: flex; + justify-content: space-between; + align-items: center; + background: #fff; + border-radius: 8px; + padding: 14px 16px; + margin-bottom: 8px; +} + +.sync-device-info { + display: flex; + flex-direction: column; +} + +.sync-device-name { + font-size: 15px; + font-weight: 500; + color: #1E293B; +} + +.sync-device-adapter { + font-size: 12px; + color: #94A3B8; + margin-top: 2px; +} + +.sync-device-rssi { + font-size: 12px; + color: #64748B; +} + +.sync-status-card { + display: flex; + align-items: center; + background: #fff; + border-radius: 8px; + padding: 14px 16px; + margin-bottom: 12px; +} + +.sync-status-dot { + width: 8px; + height: 8px; + border-radius: 4px; + margin-right: 8px; + background: #94A3B8; +} + +.sync-status-dot--connected { + background: #22C55E; +} + +.sync-status-text { + font-size: 14px; + color: #1E293B; +} + +.sync-readings-panel { + background: #fff; + border-radius: 8px; + padding: 14px 16px; + margin-bottom: 12px; +} + +.sync-reading-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #F1F5F9; +} + +.sync-reading-type { + font-size: 13px; + color: #64748B; +} + +.sync-reading-value { + font-size: 15px; + font-weight: 600; + color: #0891B2; +} + +.sync-readings-count { + display: block; + margin-top: 8px; + font-size: 12px; + color: #94A3B8; + text-align: center; +} + +.sync-actions-row { + display: flex; + gap: 8px; +} + +.sync-error { + margin: 16px; + padding: 12px 16px; + background: #FEF2F2; + border-radius: 8px; + border: 1px solid #FECACA; +} + +.sync-error-text { + font-size: 13px; + color: #DC2626; +} + +.sync-loading { + display: flex; + justify-content: center; + padding: 48px 16px; +} + +.sync-loading-text { + font-size: 14px; + color: #64748B; +} + +.sync-result-card { + display: flex; + flex-direction: column; + align-items: center; + background: #fff; + border-radius: 12px; + padding: 32px 16px; + margin-bottom: 16px; +} + +.sync-result-icon { + font-size: 40px; + color: #22C55E; + margin-bottom: 8px; +} + +.sync-result-title { + font-size: 18px; + font-weight: 600; + color: #1E293B; + margin-bottom: 4px; +} + +.sync-result-count { + font-size: 13px; + color: #64748B; +} diff --git a/apps/miniprogram/src/pages/device-sync/index.tsx b/apps/miniprogram/src/pages/device-sync/index.tsx new file mode 100644 index 0000000..00bf884 --- /dev/null +++ b/apps/miniprogram/src/pages/device-sync/index.tsx @@ -0,0 +1,216 @@ +import { useState, useCallback } from 'react'; +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 { uploadReadings } from '@/services/device-sync'; +import { useAuthStore } from '@/stores/auth'; +import type { BLEDevice, NormalizedReading } from '@/services/ble/types'; +import './index.scss'; + +const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 }); +bleManager.registerAdapter(XiaomiBandAdapter); + +type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error'; + +export default function DeviceSync() { + const { currentPatient } = useAuthStore(); + const [pageState, setPageState] = useState('idle'); + const [devices, setDevices] = useState([]); + const [selectedDevice, setSelectedDevice] = useState(null); + const [liveReadings, setLiveReadings] = useState([]); + const [syncCount, setSyncCount] = useState(0); + const [errorMsg, setErrorMsg] = useState(''); + + useDidShow(() => { + bleManager.setOnConnectionChange(() => {}); + bleManager.setOnReadings((readings) => { + setLiveReadings((prev) => [...prev, ...readings]); + }); + + return () => { + bleManager.destroy(); + }; + }); + + const handleScan = useCallback(async () => { + setPageState('scanning'); + setDevices([]); + setErrorMsg(''); + try { + const found = await bleManager.scanDevices(); + setDevices(found); + if (found.length === 0) { + setErrorMsg('未发现支持的设备,请确认手环已开启蓝牙并靠近手机'); + } + setPageState('idle'); + } catch (e: any) { + setErrorMsg(e.message || '扫描失败'); + setPageState('error'); + } + }, []); + + const handleConnect = useCallback(async (device: BLEDevice) => { + setSelectedDevice(device); + setPageState('connecting'); + setErrorMsg(''); + try { + await bleManager.connect(device); + setPageState('connected'); + } catch (e: any) { + setErrorMsg(e.message || '连接失败'); + setPageState('error'); + } + }, []); + + const handleSync = useCallback(async () => { + if (!currentPatient || !selectedDevice) return; + + setPageState('syncing'); + setErrorMsg(''); + + try { + const result = await bleManager.syncToServer(async (readings) => { + return uploadReadings( + currentPatient.id, + selectedDevice.deviceId, + selectedDevice.name, + readings, + ); + }); + + if (result.success) { + setSyncCount(result.uploadedCount); + setPageState('done'); + } else { + setErrorMsg(result.error || '同步失败'); + setPageState('error'); + } + } catch (e: any) { + setErrorMsg(e.message || '同步失败'); + setPageState('error'); + } + }, [currentPatient, selectedDevice]); + + const handleDisconnect = useCallback(async () => { + await bleManager.disconnect(); + setPageState('idle'); + setSelectedDevice(null); + setLiveReadings([]); + setSyncCount(0); + setErrorMsg(''); + }, []); + + const renderIdle = () => ( + + + + 设备同步 + 连接智能手环,自动采集健康数据 + + + + 扫描设备 + + + {devices.length > 0 && ( + + 发现的设备 + {devices.map((d) => ( + handleConnect(d)} + > + + {d.name} + {d.adapter?.name} + + 信号 {d.RSSI > -60 ? '强' : d.RSSI > -80 ? '中' : '弱'} + + ))} + + )} + + ); + + const renderConnected = () => ( + + + + 已连接: {selectedDevice?.name} + + + {liveReadings.length > 0 && ( + + 实时数据 + {liveReadings.slice(-5).reverse().map((r, i) => ( + + + {r.device_type === 'heart_rate' ? '心率' : r.device_type} + + + {r.device_type === 'heart_rate' + ? `${r.values.heart_rate} bpm` + : JSON.stringify(r.values)} + + + ))} + + 已采集 {liveReadings.length} 条数据 + + + )} + + + + 上传数据 + + + 断开连接 + + + + ); + + const renderDone = () => ( + + + + 同步完成 + 成功上传 {syncCount} 条数据 + + + 完成 + + + ); + + return ( + + + 设备同步 + + + {errorMsg && ( + + {errorMsg} + + )} + + {(pageState === 'scanning' || pageState === 'connecting' || pageState === 'syncing') && ( + + + {pageState === 'scanning' && '正在扫描设备...'} + {pageState === 'connecting' && '正在连接设备...'} + {pageState === 'syncing' && '正在上传数据...'} + + + )} + + {(pageState === 'idle' || pageState === 'error') && renderIdle()} + {pageState === 'connected' && renderConnected()} + {pageState === 'done' && renderDone()} + + ); +} diff --git a/apps/miniprogram/src/services/ble/BLEManager.ts b/apps/miniprogram/src/services/ble/BLEManager.ts new file mode 100644 index 0000000..d0c33ed --- /dev/null +++ b/apps/miniprogram/src/services/ble/BLEManager.ts @@ -0,0 +1,308 @@ +import Taro from '@tarojs/taro'; +import type { + DeviceAdapter, + BLEDevice, + BLEConnection, + BLEConnectionState, + NormalizedReading, + SyncResult, + BLEManagerConfig, +} from './types'; + +const DEFAULT_CONFIG: BLEManagerConfig = { + scanTimeout: 10000, + maxReadingsPerSync: 500, + retryCount: 3, +}; + +export class BLEManager { + private adapters: DeviceAdapter[] = []; + private connection: BLEConnection | null = null; + private readings: NormalizedReading[] = []; + private config: BLEManagerConfig; + private scanTimer: ReturnType | null = null; + private onConnectionChange?: (state: BLEConnectionState) => void; + private onReadings?: (readings: NormalizedReading[]) => void; + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** 注册设备适配器 */ + registerAdapter(adapter: DeviceAdapter): void { + this.adapters = [...this.adapters, adapter]; + } + + /** 设置连接状态回调 */ + setOnConnectionChange(cb: (state: BLEConnectionState) => void): void { + this.onConnectionChange = cb; + } + + /** 设置读数回调 */ + setOnReadings(cb: (readings: NormalizedReading[]) => void): void { + this.onReadings = cb; + } + + private updateState(state: BLEConnectionState, error?: string): void { + if (this.connection) { + this.connection = { ...this.connection, state, error }; + } + this.onConnectionChange?.(state); + } + + /** 匹配设备到适配器 */ + private matchAdapter(deviceName: string): DeviceAdapter | undefined { + const lower = deviceName.toLowerCase(); + return this.adapters.find((a) => + a.supportedModels.some((m) => lower.includes(m.toLowerCase())), + ); + } + + /** 初始化蓝牙适配器 */ + async initialize(): Promise { + try { + await Taro.openBluetoothAdapter(); + } catch (e: any) { + throw new Error(e.errMsg || '蓝牙初始化失败,请检查蓝牙是否开启'); + } + } + + /** 扫描 BLE 设备 */ + async scanDevices(): Promise { + await this.initialize(); + + const discovered = new Map(); + + const onFound = (res: any) => { + for (const device of res.devices || []) { + const name = device.name || device.localName || ''; + if (!name) continue; + const adapter = this.matchAdapter(name); + if (adapter) { + discovered.set(device.deviceId, { + deviceId: device.deviceId, + name, + RSSI: device.RSSI, + localName: device.localName, + advertisData: device.advertisData, + adapter, + }); + } + } + }; + + Taro.onBluetoothDeviceFound(onFound); + + const allServiceUUIDs = this.adapters.flatMap((a) => a.serviceUUIDs); + await Taro.startBluetoothDevicesDiscovery({ + allowDuplicatesKey: false, + services: allServiceUUIDs.length > 0 ? allServiceUUIDs : undefined, + }); + + return new Promise((resolve) => { + this.scanTimer = setTimeout(async () => { + await this.stopScan(); + Taro.offBluetoothDeviceFound(onFound); + resolve(Array.from(discovered.values())); + }, this.config.scanTimeout); + }); + } + + /** 停止扫描 */ + async stopScan(): Promise { + if (this.scanTimer) { + clearTimeout(this.scanTimer); + this.scanTimer = null; + } + try { + await Taro.stopBluetoothDevicesDiscovery(); + } catch { + // 忽略停止扫描错误 + } + } + + /** 连接到设备 */ + async connect(device: BLEDevice): Promise { + if (!device.adapter) throw new Error('设备无适配器'); + + this.connection = { + deviceId: device.deviceId, + state: 'connecting', + adapter: device.adapter, + }; + this.updateState('connecting'); + this.readings = []; + + try { + await Taro.createBLEConnection({ + deviceId: device.deviceId, + timeout: 10000, + }); + + // 监听断连 + Taro.onBLEConnectionStateChange((res: any) => { + if (res.deviceId === device.deviceId && !res.connected) { + this.updateState('disconnected', '设备断开连接'); + this.connection = null; + } + }); + + // 发现服务 + const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId }); + const services = servicesRes.services || []; + + // 启用通知 + for (const { service: svcUUID, characteristic: charUUID } of device.adapter.notifyCharacteristics) { + const svc = services.find((s: any) => s.uuid.toUpperCase().includes(svcUUID.toUpperCase())); + if (!svc) continue; + + await Taro.getBLEDeviceCharacteristics({ + deviceId: device.deviceId, + serviceId: svc.uuid, + }); + + await Taro.notifyBLECharacteristicValueChange({ + deviceId: device.deviceId, + serviceId: svc.uuid, + characteristicId: charUUID, + state: true, + }); + } + + // 监听数据通知 + Taro.onBLECharacteristicValueChange((res: any) => { + if (res.deviceId !== device.deviceId) return; + const reading = device.adapter!.parseNotification( + res.serviceId, + res.characteristicId, + res.value, + ); + if (reading) { + this.readings = [...this.readings, reading]; + this.onReadings?.([reading]); + } + }); + + this.connection = { ...this.connection, state: 'connected', connectedAt: Date.now() }; + this.updateState('connected'); + } catch (e: any) { + this.updateState('error', e.errMsg || e.message || '连接失败'); + this.connection = null; + throw new Error(e.errMsg || '蓝牙连接失败'); + } + } + + /** 手动读取特征值 */ + async readCharacteristics(): Promise { + if (!this.connection || this.connection.state !== 'connected') { + throw new Error('设备未连接'); + } + + const { deviceId, adapter } = this.connection; + const results: NormalizedReading[] = []; + + const servicesRes = await Taro.getBLEDeviceServices({ deviceId }); + const services = servicesRes.services || []; + + for (const { service: svcUUID, characteristic: charUUID } of adapter.readCharacteristics) { + const svc = services.find((s: any) => s.uuid.toUpperCase().includes(svcUUID.toUpperCase())); + if (!svc) continue; + + try { + // Taro readBLECharacteristicValue 触发 onBLECharacteristicValueChange 回调 + // 读取结果会通过 BLEManager 已注册的 onBLECharacteristicValueChange 监听器返回 + await Taro.readBLECharacteristicValue({ + deviceId, + serviceId: svc.uuid, + characteristicId: charUUID, + }); + } catch { + // 某些特征值可能不支持读取 + } + } + + if (results.length > 0) { + this.readings = [...this.readings, ...results]; + this.onReadings?.(results); + } + + return results; + } + + /** 同步收集的读数到后端 */ + async syncToServer(uploadFn: (readings: NormalizedReading[]) => Promise): Promise { + if (!this.connection) { + return { success: false, readingsCount: 0, uploadedCount: 0, error: '未连接设备' }; + } + + this.updateState('syncing'); + + const batch = this.readings.slice(-this.config.maxReadingsPerSync); + if (batch.length === 0) { + this.updateState('connected'); + return { success: true, readingsCount: 0, uploadedCount: 0 }; + } + + let lastError: string | undefined; + for (let attempt = 1; attempt <= this.config.retryCount; attempt++) { + try { + const uploaded = await uploadFn(batch); + this.readings = this.readings.slice(batch.length); + this.updateState('connected'); + return { + success: true, + readingsCount: batch.length, + uploadedCount: uploaded, + }; + } catch (e: any) { + lastError = e.message || '上传失败'; + } + } + + this.updateState('error', lastError); + return { success: false, readingsCount: batch.length, uploadedCount: 0, error: lastError }; + } + + /** 断开连接 */ + async disconnect(): Promise { + if (!this.connection) return; + + const { deviceId } = this.connection; + try { + await Taro.closeBLEConnection({ deviceId }); + } catch { + // 忽略断连错误 + } + + this.connection = null; + this.readings = []; + this.updateState('disconnected'); + } + + /** 关闭蓝牙适配器 */ + async destroy(): Promise { + await this.disconnect(); + try { + await Taro.closeBluetoothAdapter(); + } catch { + // 忽略关闭错误 + } + } + + /** 获取当前连接信息 */ + getConnection(): BLEConnection | null { + return this.connection; + } + + /** 获取缓存的读数 */ + getCachedReadings(): NormalizedReading[] { + return [...this.readings]; + } + + /** 清除缓存读数 */ + clearReadings(): void { + this.readings = []; + } +} + +export default new BLEManager(); diff --git a/apps/miniprogram/src/services/ble/adapters/XiaomiBandAdapter.ts b/apps/miniprogram/src/services/ble/adapters/XiaomiBandAdapter.ts new file mode 100644 index 0000000..859c99c --- /dev/null +++ b/apps/miniprogram/src/services/ble/adapters/XiaomiBandAdapter.ts @@ -0,0 +1,83 @@ +import type { DeviceAdapter, NormalizedReading } from '../types'; + +/** + * 小米手环 BLE 适配器 + * + * 支持 Mi Band 7/8 等型号,使用标准 Heart Rate Service 读取心率数据。 + * 未来可扩展步数(0xFEE1)、睡眠等 Mi Band 专有 Service。 + */ + +// 标准 BLE Heart Rate Service +const HRS_SERVICE = '0000180D-0000-1000-8000-00805F9B34FB'; +const HRM_CHARACTERISTIC = '00002A37-0000-1000-8000-00805F9B34FB'; + +// 小米 Mi Band 专有 Service(步数/活动量) +// const MI_BAND_SERVICE = '0000FEE1-0000-1000-8000-8000-00805F9B34FB'; + +/** 解析心率测量值(Heart Rate Measurement 格式) */ +function parseHeartRate(data: ArrayBuffer): number | null { + const view = new DataView(data); + if (view.byteLength < 2) return null; + + const flags = view.getUint8(0); + // Bit 0: Heart Rate Format — 0 = UINT8, 1 = UINT16 + const isUINT16 = (flags & 0x01) !== 0; + + if (isUINT16) { + if (view.byteLength < 3) return null; + return view.getUint16(1, true); // little-endian + } + return view.getUint8(1); +} + +export const XiaomiBandAdapter: DeviceAdapter = { + name: 'Xiaomi Band', + + supportedModels: [ + 'Mi Band', + 'Mi Smart Band', + 'Xiaomi Band', + 'Xiaomi Smart Band', + 'MI BAND', + 'MiBand', + ], + + serviceUUIDs: [HRS_SERVICE], + + notifyCharacteristics: [ + { service: HRS_SERVICE, characteristic: HRM_CHARACTERISTIC }, + ], + + readCharacteristics: [ + { service: HRS_SERVICE, characteristic: HRM_CHARACTERISTIC }, + ], + + parseNotification( + _serviceUUID: string, + charUUID: string, + data: ArrayBuffer, + ): NormalizedReading | null { + if (charUUID.toUpperCase().includes('2A37')) { + const hr = parseHeartRate(data); + if (hr !== null && hr > 0 && hr < 300) { + return { + device_type: 'heart_rate', + values: { heart_rate: hr }, + measured_at: new Date().toISOString(), + }; + } + } + return null; + }, + + parseReadResponse( + _serviceUUID: string, + _charUUID: string, + _data: ArrayBuffer, + ): NormalizedReading | null { + // 读取模式暂不支持,使用通知模式获取数据 + return null; + }, +}; + +export default XiaomiBandAdapter; diff --git a/apps/miniprogram/src/services/ble/adapters/index.ts b/apps/miniprogram/src/services/ble/adapters/index.ts new file mode 100644 index 0000000..33d3e5f --- /dev/null +++ b/apps/miniprogram/src/services/ble/adapters/index.ts @@ -0,0 +1 @@ +export { XiaomiBandAdapter } from './XiaomiBandAdapter'; diff --git a/apps/miniprogram/src/services/ble/index.ts b/apps/miniprogram/src/services/ble/index.ts new file mode 100644 index 0000000..e14c881 --- /dev/null +++ b/apps/miniprogram/src/services/ble/index.ts @@ -0,0 +1,11 @@ +export { BLEManager } from './BLEManager'; +export type { + DeviceType, + NormalizedReading, + DeviceAdapter, + BLEDevice, + BLEConnection, + BLEConnectionState, + SyncResult, + BLEManagerConfig, +} from './types'; diff --git a/apps/miniprogram/src/services/ble/types.ts b/apps/miniprogram/src/services/ble/types.ts new file mode 100644 index 0000000..5a3467c --- /dev/null +++ b/apps/miniprogram/src/services/ble/types.ts @@ -0,0 +1,89 @@ +/** BLE 模块类型定义 */ + +/** 设备数据类型(与后端 device_readings.device_type 枚举对齐) */ +export type DeviceType = + | 'heart_rate' + | 'blood_oxygen' + | 'steps' + | 'sleep' + | 'temperature' + | 'stress'; + +/** 标准化的设备读数 */ +export interface NormalizedReading { + device_type: DeviceType; + values: Record; + measured_at: string; +} + +/** BLE 设备适配器接口 — 不同品牌设备实现此接口 */ +export interface DeviceAdapter { + /** 适配器名称 */ + readonly name: string; + + /** 支持的设备型号关键字(用于匹配广播名称) */ + readonly supportedModels: string[]; + + /** 需要订阅的 Service UUID 列表 */ + readonly serviceUUIDs: string[]; + + /** 需要监听的 Characteristic(用于通知模式) */ + readonly notifyCharacteristics: { service: string; characteristic: string }[]; + + /** 需要主动读取的 Characteristic */ + readonly readCharacteristics: { service: string; characteristic: string }[]; + + /** 解析 BLE 通知数据为标准读数 */ + parseNotification( + serviceUUID: string, + charUUID: string, + data: ArrayBuffer, + ): NormalizedReading | null; + + /** 解析 BLE 读取数据为标准读数 */ + parseReadResponse( + serviceUUID: string, + charUUID: string, + data: ArrayBuffer, + ): NormalizedReading | null; +} + +/** 扫描发现的 BLE 设备 */ +export interface BLEDevice { + deviceId: string; + name: string; + RSSI: number; + localName?: string; + advertisData?: ArrayBuffer; + adapter?: DeviceAdapter; +} + +/** BLE 连接状态 */ +export type BLEConnectionState = 'disconnected' | 'connecting' | 'connected' | 'syncing' | 'error'; + +/** BLE 连接信息 */ +export interface BLEConnection { + deviceId: string; + state: BLEConnectionState; + adapter: DeviceAdapter; + connectedAt?: number; + error?: string; +} + +/** 同步操作结果 */ +export interface SyncResult { + success: boolean; + readingsCount: number; + uploadedCount: number; + error?: string; +} + +/** BLEManager 配置 */ +export interface BLEManagerConfig { + /** 扫描超时(毫秒) */ + scanTimeout: number; + /** 单次同步最大读数数量 */ + maxReadingsPerSync: number; + /** 同步失败重试次数 */ + retryCount: number; +} diff --git a/apps/miniprogram/src/services/device-sync.ts b/apps/miniprogram/src/services/device-sync.ts new file mode 100644 index 0000000..6294af9 --- /dev/null +++ b/apps/miniprogram/src/services/device-sync.ts @@ -0,0 +1,67 @@ +import { api } from './request'; +import type { NormalizedReading } from './ble/types'; + +interface BatchReadingRequest { + device_id: string; + device_model?: string; + readings: { + device_type: string; + values: Record; + measured_at: string; + }[]; +} + +interface BatchResult { + accepted: number; + duplicates: number; + earliest: string | null; + latest: string | null; +} + +/** 将标准化读数转换为后端批量请求格式并上传 */ +export async function uploadReadings( + patientId: string, + deviceId: string, + deviceModel: string | undefined, + readings: NormalizedReading[], +): Promise { + if (readings.length === 0) return 0; + + const body: BatchReadingRequest = { + device_id: deviceId, + device_model: deviceModel, + readings: readings.map((r) => ({ + device_type: r.device_type, + values: r.values, + measured_at: r.measured_at, + })), + }; + + const result = await api.post( + `/health/patients/${patientId}/device-readings/batch`, + body, + ); + return result.accepted; +} + +/** 查询设备原始数据 */ +export async function queryDeviceReadings( + patientId: string, + params?: { device_type?: string; hours?: number }, +) { + return api.get<{ data: unknown[]; total: number }>( + `/health/patients/${patientId}/device-readings`, + params, + ); +} + +/** 查询小时级降采样数据 */ +export async function queryHourlyReadings( + patientId: string, + params: { device_type: string; days?: number }, +) { + return api.get<{ data: unknown[]; total: number }>( + `/health/patients/${patientId}/device-readings/hourly`, + params, + ); +}