diff --git a/apps/miniprogram/src/pages/pkg-health/device-sync/index.scss b/apps/miniprogram/src/pages/pkg-health/device-sync/index.scss index e2e08ea..8e68c6f 100644 --- a/apps/miniprogram/src/pages/pkg-health/device-sync/index.scss +++ b/apps/miniprogram/src/pages/pkg-health/device-sync/index.scss @@ -927,3 +927,62 @@ font-size: var(--tk-font-body-lg); color: $tx2; } + +// ─── 服务发现信息 ─── +.ds-services-info { + margin-bottom: var(--tk-gap-md) !important; +} + +.ds-services-info__title { + display: block; + font-size: var(--tk-font-body-sm); + font-weight: 600; + color: $tx; + margin-bottom: var(--tk-gap-sm); +} + +.ds-services-info__caps { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ds-cap-tag { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: $r-xs; + font-size: var(--tk-font-cap); + + &--on { + background: rgba($acc, 0.08); + color: $acc; + } + + &--off { + background: $surface-alt; + color: $tx3; + } +} + +.ds-cap-tag__dot { + font-size: 10px; +} + +.ds-cap-tag__text { + font-size: var(--tk-font-cap); +} + +.ds-services-info__hint { + margin-top: var(--tk-gap-sm); + background: $wrn-l; + border-radius: $r-xs; + padding: 8px 12px; +} + +.ds-services-info__hint-text { + font-size: var(--tk-font-cap); + color: $wrn; + line-height: 1.5; +} diff --git a/apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx b/apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx index 3445090..4129e24 100644 --- a/apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx +++ b/apps/miniprogram/src/pages/pkg-health/device-sync/index.tsx @@ -10,7 +10,7 @@ import { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter } from '@/service import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler'; import { uploadReadings } from '@/services/device-sync'; import { useAuthStore } from '@/stores/auth'; -import type { BLEDevice, NormalizedReading } from '@/services/ble/types'; +import type { BLEDevice, NormalizedReading, BLEDiscoveredService } from '@/services/ble/types'; import { useElderClass } from '@/hooks/useElderClass'; import PageShell from '@/components/ui/PageShell'; import ContentCard from '@/components/ui/ContentCard'; @@ -80,6 +80,7 @@ export default function DeviceSync() { const [errorMsg, setErrorMsg] = useState(''); const [lastSyncAt, setLastSyncAt] = useState(null); const [pendingCount, setPendingCount] = useState(0); + const [discoveredServices, setDiscoveredServices] = useState([]); const scheduler = useMemo(() => new DataSyncScheduler({ intervalMs: 60 * 60 * 1000 }), []); @@ -163,6 +164,8 @@ export default function DeviceSync() { setErrorMsg(''); try { await getBleManager().connect(device); + const conn = getBleManager().getConnection(); + setDiscoveredServices(conn?.discoveredServices ?? []); setPageState('connected'); } catch (e: unknown) { setErrorMsg(e instanceof Error ? e.message : '连接失败'); @@ -217,6 +220,7 @@ export default function DeviceSync() { setLiveReadings([]); setSyncCount(0); setErrorMsg(''); + setDiscoveredServices([]); }, []); const latestReading = liveReadings.length > 0 ? liveReadings[liveReadings.length - 1] : null; @@ -359,6 +363,54 @@ export default function DeviceSync() { ); + /** 渲染 BLE 服务发现信息 */ + const renderServiceDiscovery = () => { + if (discoveredServices.length === 0) return null; + + // 检查各类健康数据是否在已发现的 UUID 中可用 + const hasCharShort = (short: string) => + discoveredServices.some((s) => + s.characteristics.some((c) => + c.uuid.toUpperCase().replace(/-/g, '').slice(-4) === short, + ), + ); + + const capabilities = [ + { key: '2A37', label: '心率', available: hasCharShort('2A37') }, + { key: '2A5F', label: '血氧(实时)', available: hasCharShort('2A5F') }, + { key: '2A5E', label: '血氧(单次)', available: hasCharShort('2A5E') }, + { key: '2A1C', label: '体温', available: hasCharShort('2A1C') }, + { key: '2A35', label: '血压', available: hasCharShort('2A35') }, + ]; + + const availableCount = capabilities.filter((c) => c.available).length; + + return ( + + + 设备服务 ({discoveredServices.length} 个服务, {availableCount} 种可用数据) + + + {capabilities.map((cap) => ( + + {cap.available ? '●' : '○'} + {cap.label} + + ))} + + {availableCount <= 1 && ( + + + {selectedDevice?.name?.includes('HUAWEI') || selectedDevice?.name?.includes('HW') + ? '华为手环的睡眠/步数/压力数据使用私有协议,需要华为运动健康 App 同步' + : '此设备仅暴露少量标准健康服务,更多数据请使用设备官方 App 同步'} + + + )} + + ); + }; + const renderLatestReading = () => { if (!latestReading) return null; return ( @@ -525,6 +577,7 @@ export default function DeviceSync() { {pageState === 'connected' && ( {renderConnectedStatus()} + {renderServiceDiscovery()} {renderLatestReading()} {renderReadingsHistory()} {renderConnectedActions()} diff --git a/apps/miniprogram/src/services/ble/BLEConnection.ts b/apps/miniprogram/src/services/ble/BLEConnection.ts index 1c2f781..db14014 100644 --- a/apps/miniprogram/src/services/ble/BLEConnection.ts +++ b/apps/miniprogram/src/services/ble/BLEConnection.ts @@ -7,6 +7,8 @@ import type { BLEConnectionChangeResult, BLECharacteristicChangeResult, BLEServiceItem, + BLEDiscoveredService, + BLEDiscoveredCharacteristic, } from './types'; /** BLE 连接管理 — 封装连接/断开/服务发现/通知订阅/数据监听 */ @@ -121,10 +123,24 @@ export class BLEConnection { } } - /** 发现服务并启用通知 */ + /** 已知的健康相关 Characteristic UUID(用于自动发现和订阅) */ + private static readonly HEALTH_CHAR_UUIDS: Record = { + '2A37': 'heart_rate', // Heart Rate Measurement + '2A38': 'heart_rate_loc', // Body Sensor Location + '2A1C': 'temperature', // Temperature Measurement + '2A35': 'blood_pressure', // Blood Pressure Measurement + '2A5F': 'blood_oxygen', // PLX Continuous Measurement + '2A5E': 'blood_oxygen_spot',// PLX Spot-Check Measurement + }; + + /** 发现服务并启用通知 — 先订阅适配器指定的,再扫描全部服务尝试自动发现 */ private async discoverServices(device: BLEDevice): Promise { const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId }); const services = servicesRes.services || []; + const discoveredServices: BLEDiscoveredService[] = []; + + // ── 第一轮:订阅适配器预定义的 Characteristic(保持向后兼容) ── + const subscribedCharUUIDs = new Set(); for (const { service: svcUUID, characteristic: charUUID } of device.adapter!.notifyCharacteristics) { const svc = services.find((s: BLEServiceItem) => @@ -137,13 +153,90 @@ export class BLEConnection { serviceId: svc.uuid, }); - await Taro.notifyBLECharacteristicValueChange({ - deviceId: device.deviceId, - serviceId: svc.uuid, - characteristicId: charUUID, - state: true, + try { + await Taro.notifyBLECharacteristicValueChange({ + deviceId: device.deviceId, + serviceId: svc.uuid, + characteristicId: charUUID, + state: true, + }); + subscribedCharUUIDs.add(charUUID.toUpperCase().replace(/-/g, '').slice(-4)); + console.log(`[ble] 已订阅适配器预定义: ${svcUUID} / ${charUUID}`); + } catch (err) { + console.warn(`[ble] 订阅失败 (预定义): ${charUUID}`, err); + } + } + + // ── 第二轮:扫描全部服务,发现并订阅健康相关 Characteristic ── + for (const svc of services) { + const svcUUID = svc.uuid.toUpperCase(); + const discoveredChars: BLEDiscoveredCharacteristic[] = []; + + let charsRes: Taro.getBLEDeviceCharacteristics.SuccessCallbackResult; + try { + charsRes = await Taro.getBLEDeviceCharacteristics({ + deviceId: device.deviceId, + serviceId: svc.uuid, + }); + } catch (err) { + console.warn(`[ble] 读取特征列表失败: ${svcUUID}`, err); + continue; + } + + const characteristics = charsRes.characteristics || []; + + for (const char of characteristics) { + const charUUIDShort = char.uuid.toUpperCase().replace(/-/g, '').slice(-4); + const props = char.properties || {}; + + const discoveredChar: BLEDiscoveredCharacteristic = { + uuid: char.uuid, + properties: { + read: !!props.read, + write: !!props.write, + notify: !!props.notify, + indicate: !!props.indicate, + }, + }; + discoveredChars.push(discoveredChar); + + // 如果是已知的健康 UUID 且尚未订阅,尝试订阅 + if ( + BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort] && + !subscribedCharUUIDs.has(charUUIDShort) && + (props.notify || props.indicate) + ) { + try { + await Taro.notifyBLECharacteristicValueChange({ + deviceId: device.deviceId, + serviceId: svc.uuid, + characteristicId: char.uuid, + state: true, + }); + subscribedCharUUIDs.add(charUUIDShort); + console.log(`[ble] 自动发现并订阅: ${BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort]} (${svcUUID} / ${char.uuid})`); + } catch (err) { + console.warn(`[ble] 自动订阅失败: ${char.uuid}`, err); + } + } + } + + discoveredServices.push({ + uuid: svc.uuid, + isPrimary: !!svc.isPrimary, + characteristics: discoveredChars, }); } + + // 存储发现结果到连接信息 + if (this.conn) { + this.conn = { ...this.conn, discoveredServices }; + } + + console.log(`[ble] 服务发现完成: ${discoveredServices.length} 个服务, 已订阅 ${subscribedCharUUIDs.size} 个特征`); + console.log(`[ble] 已订阅的健康特征:`, [...subscribedCharUUIDs].map( + (s) => `${s}(${BLEConnection.HEALTH_CHAR_UUIDS[s] ?? '未知'})` + ).join(', ')); } /** 手动读取特征值 */ diff --git a/apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts b/apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts index 48650bd..d3f972a 100644 --- a/apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts +++ b/apps/miniprogram/src/services/ble/adapters/GenericBleAdapter.ts @@ -23,8 +23,44 @@ const SERVICES: Record view.byteLength) return null; + const raw = view.getUint16(offset, true); + + if (raw === SFLOAT_NAN || raw === SFLOAT_NRES) return null; + if (raw === SFLOAT_POS_INF) return Infinity; + if (raw === SFLOAT_NEG_INF) return -Infinity; + + const signM = (raw >> 15) & 0x01; + const exp = (raw >> 12) & 0x07; + const mantissa = raw & 0x0FFF; + + // 指数用 3 位补码表示(0-3 正,4-7 负) + const exponent = exp >= 4 ? exp - 8 : exp; + const signedMantissa = signM ? -(mantissa ^ 0x0FFF) - 1 : mantissa; + + return signedMantissa * Math.pow(10, exponent); +} + // ── 解析器 ── function parseHeartRate(data: ArrayBuffer): NormalizedReading | null { @@ -66,6 +102,39 @@ function parseTemperature(data: ArrayBuffer): NormalizedReading | null { }; } +/** + * 解析 Pulse Oximeter Service 数据 + * PLX Continuous Measurement (0x2A5F) 和 Spot-Check (0x2A5E) 共用 + * 格式: Flags(1B) + SpO2(SFLOAT 2B) + PulseRate(SFLOAT 2B) + optional... + */ +function parsePulseOximeter(data: ArrayBuffer): NormalizedReading[] { + const view = new DataView(data); + if (view.byteLength < 5) return []; + + const spO2 = parseSFLOAT(view, 1); + const pulseRate = parseSFLOAT(view, 3); + const now = new Date().toISOString(); + const results: NormalizedReading[] = []; + + if (spO2 !== null && spO2 >= 0 && spO2 <= 100) { + results.push({ + device_type: 'blood_oxygen', + values: { blood_oxygen: Math.round(spO2), unit: '%' }, + measured_at: now, + }); + } + + if (pulseRate !== null && pulseRate > 0 && pulseRate <= 300) { + results.push({ + device_type: 'heart_rate', + values: { heart_rate: Math.round(pulseRate) }, + measured_at: now, + }); + } + + return results; +} + // ── 工厂函数 ── export interface GenericAdapterConfig { @@ -100,20 +169,23 @@ export function createGenericBleAdapter(config: GenericAdapterConfig): DeviceAda ): NormalizedReading[] { const upper = charUUID.toUpperCase(); - // Heart Rate Measurement - const hrsChar = SERVICES.heart_rate.chars.notify.toUpperCase(); - if (upper === hrsChar || upper.includes('2A37')) { + // Heart Rate Measurement (0x2A37) + if (upper.includes('2A37')) { const result = parseHeartRate(data); return result ? [result] : []; } - // Temperature Measurement - const htChar = SERVICES.health_thermometer.chars.notify.toUpperCase(); - if (upper === htChar || upper.includes('2A1C')) { + // Temperature Measurement (0x2A1C) + if (upper.includes('2A1C')) { const result = parseTemperature(data); return result ? [result] : []; } + // Pulse Oximeter Continuous (0x2A5F) / Spot-Check (0x2A5E) + if (upper.includes('2A5F') || upper.includes('2A5E')) { + return parsePulseOximeter(data); + } + return []; }, @@ -155,7 +227,7 @@ export const HuaweiBandAdapter = createGenericBleAdapter({ '华为手环', '华为手表', ], - profiles: ['heart_rate', 'health_thermometer'], + profiles: ['heart_rate', 'health_thermometer', 'pulse_oximeter'], }); /** diff --git a/apps/miniprogram/src/services/ble/types.ts b/apps/miniprogram/src/services/ble/types.ts index 9f456fb..8a34961 100644 --- a/apps/miniprogram/src/services/ble/types.ts +++ b/apps/miniprogram/src/services/ble/types.ts @@ -72,6 +72,8 @@ export interface BLEConnection { adapter: DeviceAdapter; connectedAt?: number; error?: string; + /** 连接后扫描到的全部服务(用于调试和展示) */ + discoveredServices?: BLEDiscoveredService[]; } /** 同步操作结果 */ @@ -96,7 +98,20 @@ export interface BLEManagerConfig { export type GenericBLEProfile = | 'heart_rate' // Heart Rate Service (0x180D) | 'health_thermometer' // Health Thermometer Service (0x1809) - | 'blood_pressure'; // Blood Pressure Service (0x1810) + | 'blood_pressure' // Blood Pressure Service (0x1810) + | 'pulse_oximeter'; // Pulse Oximeter Service (0x1822) + +/** BLE 服务发现结果(连接后扫描到的全部服务/特征) */ +export interface BLEDiscoveredCharacteristic { + uuid: string; + properties: { read: boolean; write: boolean; notify: boolean; indicate: boolean }; +} + +export interface BLEDiscoveredService { + uuid: string; + isPrimary: boolean; + characteristics: BLEDiscoveredCharacteristic[]; +} /** 微信 BLE 扫描回调结果 */ export interface BLEScanResult {