Files
AI-Health/docs/omron_bp_integration.md
MingNian fc7150ad60 feat: 统一智能体路由 + 删除 onboarding
- 新增 Unified 智能体(合并 health/diet/medication/exercise/default 全部工具)
- AI 自动判断意图并调用对应工具,前端胶囊自动切换
- 删除 onboarding 智能体及所有建档引导 UI
- 前端始终发 unified 类型,通过 tool_result 事件同步胶囊状态
2026-06-08 14:55:38 +08:00

826 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 欧姆龙蓝牙血压计接入方案iOS + Android 双平台)
## 一、选购建议
### 选用型号J735
| 项目 | 详情 |
|------|------|
| **型号** | 欧姆龙 J735 |
| **价格** | ¥265-389百亿补贴约 ¥265日常 ¥350 |
| **蓝牙** | ✅ BLE 4.0,标准协议 `0x1810` |
| **精度** | ±3mmHgAAMI 认证 |
| **特点** | 双人模式各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 → kPa0 → 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; // 设备用户IDJ760 双人模式)
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<List<int>>? _subscription;
final _readingsController = StreamController<BpReading>.broadcast();
Stream<BpReading> get readings => _readingsController.stream;
// ── 扫描 ──
Stream<ScanResult> 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<void> 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不是 Notifyflutter_blue_plus 自动处理
await measurementChar.setNotifyValue(true);
_subscription = measurementChar.lastValueStream.listen(_parseReading);
// 连接后同步当前时间到设备(让测量时间戳准确)
await _syncTime(bpService);
}
// ── 解析测量数据 ──
void _parseReading(List<int> 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<void> _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<void> disconnect() async {
_subscription?.cancel();
await _device?.disconnect();
_device = null;
}
void dispose() {
disconnect();
_readingsController.close();
}
}
```
#### 3.3.3 数据同步到后端
```dart
// 在 Provider 中监听
final omronReadingsProvider = StreamProvider<BpReading>((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
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- Android 11 及以下需要定位权限来扫描蓝牙 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
```
#### iOS (`Info.plist`)
```xml
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接血压计</string>
```
---
## 四、交互流程设计
### 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<DeviceScanPage> createState() => _DeviceScanPageState();
}
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
final _ble = OmronBleService();
final _results = <ScanResult>[];
bool _scanning = false;
String? _connectingId;
@override void initState() {
super.initState();
_startScan();
}
Future<void> _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<void> _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<DeviceManagerPage> createState() => _DeviceManagerPageState();
}
class _DeviceManagerPageState extends ConsumerState<DeviceManagerPage> {
String? _boundMac;
String? _boundName;
String? _lastSync;
bool _loading = true;
@override void initState() {
super.initState();
_loadBoundDevice();
}
Future<void> _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<void> _unbind() async {
final ok = await showDialog<bool>(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<void> _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` CharacteristicUUID `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