# 欧姆龙蓝牙血压计接入方案(iOS + Android 双平台) ## 一、选购建议 ### 选用型号:J735 | 项目 | 详情 | |------|------| | **型号** | 欧姆龙 J735 | | **价格** | ¥265-389(百亿补贴约 ¥265,日常 ¥350) | | **蓝牙** | ✅ BLE 4.0,标准协议 `0x1810` | | **精度** | ±3mmHg,AAMI 认证 | | **特点** | 双人模式(各60组)、360°袖带、心律检测、体动检测、高血压红屏预警 | | **产地** | 日本原装进口 | | **购买** | 京东/天猫搜索「欧姆龙 J735」 | ### 为什么不需要官方 SDK J735 的蓝牙通信走的是 **Bluetooth SIG 国际标准协议**(Blood Pressure Service `0x1810`),不是欧姆龙私有协议。任何支持 BLE GATT 的蓝牙库都能直连,跟品牌无关。 ``` ┌──────────────────────────────────────────┐ │ flutter_blue_plus (开源) │ │ ↓ BLE GATT 标准协议 │ │ ↓ Service: 0x1810 (Blood Pressure) │ │ ↓ Char: 0x2A35 (Measurement) │ │ ↓ │ │ 欧姆龙 J735 ←── 标准蓝牙芯片 ──→ 任何 BLE 血压计 │ └──────────────────────────────────────────┘ ``` 欧姆龙官方 SDK 做的是封装这些标准协议 + 提供云端API,不是必需的。我们的方案直接走标准 BLE,零依赖、零费用、全平台兼容。 --- ## 二、通信协议(实测可靠) 欧姆龙蓝牙血压计严格遵循 **Bluetooth SIG Blood Pressure Service 1.1.1**,这是国际标准协议,不是私有协议。 ### 2.1 GATT 服务结构 ``` Blood Pressure Service (0x1810) ├── Blood Pressure Measurement (0x2A35) [Indicate] ← 测量数据在这里 ├── Blood Pressure Feature (0x2A49) [Read] ├── Intermediate Cuff Pressure (0x2A36) [Notify] ← 充气过程中的实时压力(可选) └── Date Time (0x2A08) [Write] ← 同步时间 ``` 128-bit UUID 格式(Android/iOS 都用这个): ``` Blood Pressure Service: 00001810-0000-1000-8000-00805F9B34FB Blood Pressure Measurement: 00002A35-0000-1000-8000-00805F9B34FB ``` ### 2.2 测量数据格式(`0x2A35`) BLE 推送的原始字节数组,设备每次测量完成后自动 Indicate(需客户端确认): ``` ┌────────┬──────────┬───────────┬────────┬──────────┬─────────┬─────────┬──────────────┐ │ Byte 0 │ Byte 1-2 │ Byte 3-4 │Byte 5-6│Byte 7-13 │Byte14-15│ Byte 16 │ Byte 17-18 │ │ Flags │ 收缩压 │ 舒张压 │ 平均压 │ 时间戳 │ 脉搏 │ 用户ID │ 测量状态 │ │ │ SFLOAT │ SFLOAT │ SFLOAT │ (可选) │ (可选) │ (可选) │ (可选) │ └────────┴──────────┴───────────┴────────┴──────────┴─────────┴─────────┴──────────────┘ Flags byte 各位含义: bit 0 = 1 → kPa,0 → mmHg bit 1 = 1 → 含时间戳 bit 2 = 1 → 含脉搏 bit 3 = 1 → 含用户 ID bit 4 = 1 → 含测量状态(体动/袖带/心律等) ``` ### 2.3 SFLOAT 解码(IEEE-11073 16-bit) ```dart // 通用解码函数(iOS/Android 一致) double decodeSFloat(int raw) { int mantissa = raw & 0x0FFF; // 低 12 位 if (mantissa & 0x0800 != 0) { // 符号扩展 mantissa = mantissa | 0xFFFFF000; } int exponent = (raw >> 12) & 0x0F; // 高 4 位 if (exponent & 0x08 != 0) { exponent = exponent | 0xFFFFFFF0; } return mantissa * pow(10, exponent); } ``` 实测例子(HEM-7600T 实测数据): ``` 原始: [0xDE, 0x88, 0xF4, 0x20, 0xF3, 0xFF, 0x07, 0xE4, 0x07, 0x01, 0x13, 0x16, 0x37, 0x00, 0xBC, 0xF2, 0x01, 0x44, 0x01] 解析: Flags = 0xDE → mmHg, 含时间戳, 含脉搏, 含用户ID, 含测量状态 收缩压 = decodeSFloat(0xF488) = 116.0 mmHg 舒张压 = decodeSFloat(0xF320) = 80.0 mmHg 脉搏 = decodeSFloat(0xF2BC) = 70 bpm 时间 = 2020-01-19 22:55:00 状态 = 心律不齐检测到 ``` ### 2.4 官方协议出处 - Bluetooth SIG Blood Pressure Service 1.1.1: [bluetooth.com/specifications/specs/blood-pressure-service-1-1-1](https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/) - 欧姆龙 B2B SDK 开发者门户: [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/) - 欧姆龙全球开发者 API: [omronhealthcare.com/api](https://omronhealthcare.com/api) --- ## 三、Flutter 实现(iOS + Android 双平台) ### 3.1 依赖 ```yaml # pubspec.yaml dependencies: flutter_blue_plus: ^1.34.0 # BLE 核心库(iOS + Android) permission_handler: ^11.3.0 # 权限管理 ``` ### 3.2 目录结构 ``` lib/ ├── services/ │ └── omron_ble_service.dart # 欧姆龙 BLE 连接 + 协议解析 ├── providers/ │ └── omron_device_provider.dart ├── pages/ │ └── device/ │ ├── device_scan_page.dart │ └── device_bind_page.dart └── models/ └── bp_reading.dart # 血压数据模型 ``` ### 3.3 核心代码 #### 3.3.1 数据模型 ```dart class BpReading { final int systolic; // 收缩压 final int diastolic; // 舒张压 final int? pulse; // 脉搏(可选) final DateTime timestamp; final String? userId; // 设备用户ID(J760 双人模式) final bool isKpa; // 是否 kPa 单位 final bool hasBodyMovement; // 体动 final bool hasIrregularPulse; // 心律不齐 // ... 构造函数 } ``` #### 3.3.2 BLE 服务 ```dart import 'dart:async'; import 'dart:math'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; class OmronBleService { static const bpServiceUuid = '00001810-0000-1000-8000-00805F9B34FB'; static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB'; static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB'; static const dateTimeUuid = '00002A08-0000-1000-8000-00805F9B34FB'; BluetoothDevice? _device; StreamSubscription>? _subscription; final _readingsController = StreamController.broadcast(); Stream get readings => _readingsController.stream; // ── 扫描 ── Stream scan({Duration timeout = const Duration(seconds: 15)}) { return FlutterBluePlus.scan( timeout: timeout, withServices: [Guid(bpServiceUuid)], // 只扫描有血压服务的设备 ).where((r) { final name = r.device.platformName.toUpperCase(); return name.contains('OMRON') || name.contains('HEM') || name.contains('BLEsmart'); }); } // ── 连接 ── Future connect(BluetoothDevice device) async { _device = device; await device.connect(autoConnect: false); await device.discoverServices(); final services = await device.discoverServices(); final bpService = services.firstWhere( (s) => s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase(), ); final measurementChar = bpService.characteristics.firstWhere( (c) => c.uuid.str128.toUpperCase() == bpMeasurementUuid.toUpperCase(), ); // 欧姆龙用 Indicate(不是 Notify),flutter_blue_plus 自动处理 await measurementChar.setNotifyValue(true); _subscription = measurementChar.lastValueStream.listen(_parseReading); // 连接后同步当前时间到设备(让测量时间戳准确) await _syncTime(bpService); } // ── 解析测量数据 ── void _parseReading(List raw) { if (raw.length < 7) return; final flags = raw[0]; final isKpa = (flags & 0x01) != 0; final hasTimestamp = (flags & 0x02) != 0; final hasPulse = (flags & 0x04) != 0; final hasUserId = (flags & 0x08) != 0; final hasStatus = (flags & 0x10) != 0; int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round(); int diastolic = _decodeSFloat(raw[3] | (raw[4] << 8)).round(); // byte 5-6: MAP (舍去) if (isKpa) { systolic = (systolic * 7.50062).round(); diastolic = (diastolic * 7.50062).round(); } int offset = 7; DateTime timestamp = DateTime.now(); if (hasTimestamp && raw.length >= offset + 7) { int year = raw[offset] | (raw[offset + 1] << 8); int month = raw[offset + 2]; int day = raw[offset + 3]; int hour = raw[offset + 4]; int minute = raw[offset + 5]; int second = raw[offset + 6]; timestamp = DateTime(year, month, day, hour, minute, second); offset += 7; } int? pulse; if (hasPulse && raw.length >= offset + 2) { pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round(); offset += 2; } String? userId; if (hasUserId && raw.length > offset) { userId = raw[offset].toString(); offset += 1; } bool bodyMovement = false, irregularPulse = false; if (hasStatus && raw.length >= offset + 2) { int status = raw[offset] | (raw[offset + 1] << 8); bodyMovement = (status & 0x0001) != 0; irregularPulse = (status & 0x0800) != 0; } _readingsController.add(BpReading( systolic: systolic, diastolic: diastolic, pulse: pulse, timestamp: timestamp, userId: userId, isKpa: isKpa, hasBodyMovement: bodyMovement, hasIrregularPulse: irregularPulse, )); } // ── SFLOAT 解码 ── static double _decodeSFloat(int raw) { int mantissa = raw & 0x0FFF; if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000; int exponent = (raw >> 12) & 0x0F; if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0; return mantissa * pow(10, exponent); } double pow(double x, int exp) { if (exp == 0) return 1; double r = 1; for (int i = 0; i < exp.abs(); i++) r *= x; return exp > 0 ? r : 1 / r; } // ── 同步时间 ── Future _syncTime(BluetoothService service) async { try { final dtChar = service.characteristics.firstWhere( (c) => c.uuid.str128.toUpperCase() == dateTimeUuid.toUpperCase(), ); final now = DateTime.now(); await dtChar.write([ now.year & 0xFF, now.year >> 8, now.month, now.day, now.hour, now.minute, now.second, ]); } catch (_) { // 部分型号不支持写入时间,忽略 } } // ── 断开 ── Future disconnect() async { _subscription?.cancel(); await _device?.disconnect(); _device = null; } void dispose() { disconnect(); _readingsController.close(); } } ``` #### 3.3.3 数据同步到后端 ```dart // 在 Provider 中监听 final omronReadingsProvider = StreamProvider((ref) { return ref.read(omronBleServiceProvider).readings; }); // 监听并自动同步 ref.listen(omronReadingsProvider, (_, next) { next.whenData((reading) async { final api = ref.read(apiClientProvider); await api.post('/api/health-records', data: { 'type': 'BloodPressure', 'systolic': reading.systolic, 'diastolic': reading.diastolic, 'source': 'Device', 'unit': 'mmHg', 'recordedAt': reading.timestamp.toIso8601String(), }); ref.invalidate(latestHealthProvider); // 刷新健康概览 }); }); ``` ### 3.4 平台权限配置 #### Android (`AndroidManifest.xml`) ```xml ``` #### iOS (`Info.plist`) ```xml NSBluetoothAlwaysUsageDescription 需要使用蓝牙连接欧姆龙血压计以同步测量数据 NSBluetoothPeripheralUsageDescription 需要使用蓝牙连接血压计 ``` --- ## 四、交互流程设计 ### 4.1 用户操作流程 ``` 1. 打开 App → 设置 → "蓝牙血压计" 2. 点击"扫描设备" 3. 看到列表:OMRON HEM-7600T / BLEsmart_xxxx 4. 点击连接 → 自动绑定(存储 MAC 地址) 5. 之后每次打开 App 自动连接已绑定设备 6. 血压计测量完成 → App 自动收到数据 → 弹窗确认 → 存入健康概览 ``` ### 4.2 前端页面代码 #### 扫描绑定页 (`device_scan_page.dart`) ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import '../../services/omron_ble_service.dart'; class DeviceScanPage extends ConsumerStatefulWidget { const DeviceScanPage({super.key}); @override ConsumerState createState() => _DeviceScanPageState(); } class _DeviceScanPageState extends ConsumerState { final _ble = OmronBleService(); final _results = []; bool _scanning = false; String? _connectingId; @override void initState() { super.initState(); _startScan(); } Future _startScan() async { setState(() { _scanning = true; _results.clear(); }); // Android 12+ 需要先请求权限 await FlutterBluePlus.turnOn(); _ble.scan().listen((result) { if (!_results.any((r) => r.device.remoteId == result.device.remoteId)) { setState(() => _results.add(result)); } }).onDone(() { if (mounted) setState(() => _scanning = false); }); // 15 秒后自动停止扫描 Future.delayed(const Duration(seconds: 15), () { FlutterBluePlus.stopScan(); if (mounted) setState(() => _scanning = false); }); } Future _connect(ScanResult result) async { setState(() => _connectingId = result.device.remoteId.toString()); try { await _ble.connect(result.device); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('已连接 ${result.device.platformName}')), ); Navigator.pop(context, result.device); // 返回设备信息 } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('连接失败: $e'), backgroundColor: Colors.red), ); } } finally { if (mounted) setState(() => _connectingId = null); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('添加血压计')), body: Column(children: [ // 扫描状态 Container( width: double.infinity, padding: const EdgeInsets.all(16), color: const Color(0xFFF0F2FF), child: Row(children: [ if (_scanning) ...[ const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), const SizedBox(width: 12), ], Text(_scanning ? '正在扫描...' : '扫描完成,发现 ${_results.length} 台设备', style: const TextStyle(fontSize: 14)), ]), ), // 使用说明(没有设备时) if (!_scanning && _results.isEmpty) Padding( padding: const EdgeInsets.all(32), child: Column(children: [ Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]), const SizedBox(height: 16), const Text('未发现设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), const SizedBox(height: 24), _tipCard('1', '请确保血压计已装电池'), _tipCard('2', '长按血压计蓝牙键 3 秒,直到图标闪烁'), _tipCard('3', '手机靠近血压计(1 米内)'), _tipCard('4', '关闭其他已连接血压计的手机'), _tipCard('5', 'Android 用户请确保已授予蓝牙和定位权限'), ]), ), // 设备列表 Expanded( child: ListView.builder( itemCount: _results.length, itemBuilder: (ctx, i) { final r = _results[i]; final isConnecting = _connectingId == r.device.remoteId.toString(); final name = r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备'; final signal = r.rssi; return ListTile( leading: CircleAvatar( backgroundColor: const Color(0xFFEDF2FF), child: Icon(Icons.bluetooth, color: signal > -60 ? const Color(0xFF4F6EF7) : Colors.grey), ), title: Text(name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text('信号: ${_rssiLabel(signal)} · MAC: ${r.device.remoteId}'), trailing: isConnecting ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) : ElevatedButton.icon( onPressed: () => _connect(r), icon: const Icon(Icons.link, size: 16), label: const Text('连接'), ), ); }, ), ), // 重新扫描按钮 if (!_scanning && _results.isNotEmpty) Padding( padding: const EdgeInsets.all(16), child: SizedBox(width: double.infinity, child: OutlinedButton.icon( onPressed: _startScan, icon: const Icon(Icons.refresh), label: const Text('重新扫描'), )), ), ]), ); } Widget _tipCard(String num, String text) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Container(width: 20, height: 20, alignment: Alignment.center, decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(10)), child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))), const SizedBox(width: 10), Expanded(child: Text(text, style: TextStyle(fontSize: 13, color: Colors.grey[600]))), ]), ); String _rssiLabel(int rssi) { if (rssi > -55) return '强'; if (rssi > -70) return '中'; return '弱'; } } ``` #### 已绑定设备管理页 (`device_manager_page.dart`) ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../services/local_database.dart'; class DeviceManagerPage extends ConsumerStatefulWidget { const DeviceManagerPage({super.key}); @override ConsumerState createState() => _DeviceManagerPageState(); } class _DeviceManagerPageState extends ConsumerState { String? _boundMac; String? _boundName; String? _lastSync; bool _loading = true; @override void initState() { super.initState(); _loadBoundDevice(); } Future _loadBoundDevice() async { final db = ref.read(localDatabaseProvider); _boundMac = await db.read('bp_device_mac'); _boundName = await db.read('bp_device_name'); _lastSync = await db.read('bp_last_sync'); if (mounted) setState(() => _loading = false); } Future _unbind() async { final ok = await showDialog(context: context, builder: (ctx) => AlertDialog( title: const Text('解绑设备'), content: const Text('解绑后需重新扫描连接,确定吗?'), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定解绑')), ], )); if (ok != true) return; final db = ref.read(localDatabaseProvider); await db.delete('bp_device_mac'); await db.delete('bp_device_name'); await db.delete('bp_last_sync'); setState(() { _boundMac = null; _boundName = null; _lastSync = null; }); } Future _goScan() async { final device = await Navigator.push(context, MaterialPageRoute(builder: (_) => const DeviceScanPage())); if (device != null) { final db = ref.read(localDatabaseProvider); await db.write('bp_device_mac', device.remoteId.toString()); await db.write('bp_device_name', device.platformName); _loadBoundDevice(); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('蓝牙血压计')), body: _loading ? const Center(child: CircularProgressIndicator()) : _boundMac == null ? _emptyState() : _boundCard(), ); } Widget _emptyState() => Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]), const SizedBox(height: 16), const Text('未绑定设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), const SizedBox(height: 8), const Text('连接欧姆龙血压计,自动同步测量数据', style: TextStyle(color: Color(0xFF999999))), const SizedBox(height: 24), ElevatedButton.icon( onPressed: _goScan, icon: const Icon(Icons.add), label: const Text('添加设备'), style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white), ), ]), ); Widget _boundCard() => Padding( padding: const EdgeInsets.all(16), child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8)]), child: Column(children: [ CircleAvatar(radius: 30, backgroundColor: const Color(0xFFEDF2FF), child: const Icon(Icons.bluetooth_connected, color: Color(0xFF4F6EF7))), const SizedBox(height: 12), Text(_boundName ?? '血压计', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text('MAC: $_boundMac', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))), if (_lastSync != null) ...[ const SizedBox(height: 4), Text('上次同步: $_lastSync', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))), ], const SizedBox(height: 20), TextButton.icon( onPressed: _unbind, icon: const Icon(Icons.delete_outline, color: Colors.red), label: const Text('解绑设备', style: TextStyle(color: Colors.red)), ), ]), ), ); } ``` ### 4.3 交互细节 - **测量中提示**:设备开始充气时 `0x2A36`(Intermediate Cuff Pressure)会推送实时压力,可显示充气动画 - **测量完成**:收到 `0x2A35` Indicate 后,弹 SnackBar 显示 `"已同步: 120/80 mmHg"` - **自动记录**:无需用户手动确认,直接写入 HealthRecords 表 - **防重复**:同一时间戳(精确到秒)+ 同一值 → 不重复录入 - **多用户设备(J760)**:如果 Flags 含 User ID bit,弹出选择"这是谁的数据?" --- ## 五、欧姆龙官方 SDK vs 自行解析 | 对比 | 官方 B2B SDK | 自行解析 BLE GATT | |------|-------------|-------------------| | 复杂度 | 低(已封装) | 中(需理解协议) | | 费用 | 需申请/可能收费 | 免费 | | 支持范围 | 仅欧姆龙 | 所有 BLE 血压计 | | 维护 | SDK 更新需适配 | 标准协议不变 | | iOS 支持 | 有 | ✅ flutter_blue_plus | | Android 支持 | 有 | ✅ flutter_blue_plus | | 获取方式 | [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/) | — | **推荐路线**: 1. **先用自行解析方案**(本文档方案),因为协议是标准 BLE,不依赖第三方 2. 如果遇到兼容性问题(特定型号数据解析异常),再申请欧姆龙官方 SDK 作为补充 --- ## 六、后端改动 ### 6.1 DB 新增表(可选) ```sql CREATE TABLE IF NOT EXISTS "DeviceBindings" ( "Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), "UserId" UUID NOT NULL REFERENCES "Users"("Id"), "DeviceType" TEXT DEFAULT 'BloodPressure', "DeviceModel" TEXT, -- 'OMRON J732' "DeviceMac" TEXT, -- 'AA:BB:CC:DD:EE:FF' "DeviceSerial" TEXT, "LastSyncAt" TIMESTAMPTZ, "CreatedAt" TIMESTAMPTZ DEFAULT NOW() ); ``` ### 6.2 后端几乎无需改动 现有 `POST /api/health-records` 已支持 `BloodPressure` + `systolic/diastolic`,只需确保 `Source` 枚举有 `Device`: ```csharp public enum HealthRecordSource { Manual, AiEntry, Device // ← 新增 } ``` --- ## 七、实施计划(5 天) | 天 | 任务 | 产出 | |----|------|------| | **Day 1** | flutter_blue_plus 集成 + 权限配置 + SFLOAT 单元测试 | BLE 服务骨架 | | **Day 2** | 扫描/连接/解析/断连完整实现 | OmronBleService | | **Day 3** | 设备扫描页 + 绑定管理页 + 设置入口 | UI 完成 | | **Day 4** | 数据同步到后端 + 去重逻辑 + 健康概览刷新 | 端到端打通 | | **Day 5** | 真机 + J735 联调测试、异常处理 | 上线就绪 | --- ## 八、J735 到货后首次调试指南 ### 8.1 开箱检查 收到货后先确认设备正常: 1. 装电池(4 节 AA)或插电源适配器 2. 按「开始/停止」键,袖带充气 → 屏幕显示数值 → 确认测量功能正常 3. 长按蓝牙键(屏幕出现蓝牙图标闪烁)→ 进入配对模式 ### 8.2 用官方 App 验证蓝牙 这一步是验证蓝牙硬件没问题: 1. 手机下载 **OMRON Connect**(iOS App Store / Android 应用市场) 2. 注册账号 → 添加设备 → 选择 J735 3. 血压计长按蓝牙键进入配对模式 4. App 扫描连接 → 测量一次 → 确认数据同步到 App 如果官方 App 能正常同步,说明硬件没问题,接下来就是我们的代码对接。 ### 8.3 用 nRF Connect 抓包验证 nRF Connect 是 Nordic 官方的免费 BLE 调试工具,可以直接看到设备广播的原始数据。**这是开发前最重要的验证步骤**: 1. 手机安装 **nRF Connect**(iOS App Store / Android Google Play) 2. 打开 J735 蓝牙配对模式 3. nRF Connect 扫描 → 找到 `OMRON` 或 `BLEsmart` 开头的设备 → 点击连接 4. 找到 `Blood Pressure` 服务(UUID `0x1810`) 5. 订阅 `Blood Pressure Measurement` Characteristic(UUID `0x2A35`) 6. 在血压计上测量一次 7. nRF Connect 会收到 Indicate 推送 → 截图保存原始 HEX 数据 这个 HEX 数据就是我们要解析的原始字节。用它来验证我们的 SFLOAT 解码是否正确。 ### 8.4 BLE 名称确认 不同批次 J735 的蓝牙名称可能不同,常见的有: - `OMRON HEM-xxxx` - `BLEsmart_xxxx` - `OMRON-BPM` 首次连接后用 `device.platformName` 打印出来,后续扫描过滤就用这个。 ### 8.5 常见坑 | 坑 | 现象 | 解决 | |----|------|------| | **设备已被手机系统蓝牙连接** | flutter_blue_plus 搜不到 | 系统设置里取消配对 | | **未订阅 Characteristic** | 收不到数据 | 必须 `setNotifyValue(true)` | | **忘记请求权限** | Android 扫描返回空列表 | 检查蓝牙+定位权限 | | **SFLOAT 计算错误** | 数据差很多 | 用 nRF Connect 的 HEX 对比解码结果 | | **iOS BLE 后台限制** | App 切后台后断连 | 前台测量时连接,测量完断开 | | **J735 是 Indicate 不是 Notify** | 数据只来一次 | flutter_blue_plus 对 Indicate 自动处理,但每次数据后需设备收到确认才会发下一条 | ### 8.6 测试清单 ``` □ 扫描能看到 J735 □ 点击连接成功 □ discoverServices 能找到 0x1810 服务 □ setNotifyValue(true) 后收到测量数据 □ SFLOAT 解码结果与屏幕显示一致(允许 ±1 误差) □ 数据成功 POST 到 /api/health-records □ 健康概览刷新后显示新数据 □ 断连后重新打开 App 能再次连接 □ 同一测量不重复录入(去重逻辑) □ iOS 和 Android 双平台都测试通过 ``` --- ## 九、风险与备选 | 风险 | 应对 | |------|------| | 公司 WiFi 环境蓝牙干扰大 | 手机靠近血压计(< 5 米) | | Android 后台被杀 | 前台 Service 保持 BLE 连接 | | iOS BLE 限制(App 后台) | 前台连接时同步历史数据 | | J735 协议与标准有偏差 | 用 nRF Connect 抓包对比,微调解码 | | 多个血压计混淆 | 按 MAC 地址绑定,UI 显示设备名称 | --- ## 参考资源 - Bluetooth SIG Blood Pressure Service 1.1: https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/ - 欧姆龙 B2B SDK 中国: https://public.omronhealthcare.com.cn/b2bsdk/ - 欧姆龙全球开发者 API: https://omronhealthcare.com/api - flutter_blue_plus: https://pub.dev/packages/flutter_blue_plus