# 欧姆龙蓝牙血压计接入方案（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<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 数据同步到后端

```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` 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
