- 新增 Unified 智能体(合并 health/diet/medication/exercise/default 全部工具) - AI 自动判断意图并调用对应工具,前端胶囊自动切换 - 删除 onboarding 智能体及所有建档引导 UI - 前端始终发 unified 类型,通过 tool_result 事件同步胶囊状态
29 KiB
29 KiB
欧姆龙蓝牙血压计接入方案(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)
// 通用解码函数(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
- 欧姆龙 B2B SDK 开发者门户: public.omronhealthcare.com.cn/b2bsdk
- 欧姆龙全球开发者 API: omronhealthcare.com/api
三、Flutter 实现(iOS + Android 双平台)
3.1 依赖
# 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 数据模型
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 服务
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(不是 Notify),flutter_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 数据同步到后端
// 在 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)
<!-- 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)
<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)
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)
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)会推送实时压力,可显示充气动画 - 测量完成:收到
0x2A35Indicate 后,弹 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 | — |
推荐路线:
- 先用自行解析方案(本文档方案),因为协议是标准 BLE,不依赖第三方
- 如果遇到兼容性问题(特定型号数据解析异常),再申请欧姆龙官方 SDK 作为补充
六、后端改动
6.1 DB 新增表(可选)
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:
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 开箱检查
收到货后先确认设备正常:
- 装电池(4 节 AA)或插电源适配器
- 按「开始/停止」键,袖带充气 → 屏幕显示数值 → 确认测量功能正常
- 长按蓝牙键(屏幕出现蓝牙图标闪烁)→ 进入配对模式
8.2 用官方 App 验证蓝牙
这一步是验证蓝牙硬件没问题:
- 手机下载 OMRON Connect(iOS App Store / Android 应用市场)
- 注册账号 → 添加设备 → 选择 J735
- 血压计长按蓝牙键进入配对模式
- App 扫描连接 → 测量一次 → 确认数据同步到 App
如果官方 App 能正常同步,说明硬件没问题,接下来就是我们的代码对接。
8.3 用 nRF Connect 抓包验证
nRF Connect 是 Nordic 官方的免费 BLE 调试工具,可以直接看到设备广播的原始数据。这是开发前最重要的验证步骤:
- 手机安装 nRF Connect(iOS App Store / Android Google Play)
- 打开 J735 蓝牙配对模式
- nRF Connect 扫描 → 找到
OMRON或BLEsmart开头的设备 → 点击连接 - 找到
Blood Pressure服务(UUID0x1810) - 订阅
Blood Pressure MeasurementCharacteristic(UUID0x2A35) - 在血压计上测量一次
- nRF Connect 会收到 Indicate 推送 → 截图保存原始 HEX 数据
这个 HEX 数据就是我们要解析的原始字节。用它来验证我们的 SFLOAT 解码是否正确。
8.4 BLE 名称确认
不同批次 J735 的蓝牙名称可能不同,常见的有:
OMRON HEM-xxxxBLEsmart_xxxxOMRON-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