refactor: 重构蓝牙设备页与新增设备页的扫描-连接-录入流程

- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID)
- 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿
- 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据
- 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送)
- 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect)
- 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认
- 顺手更新本机开发 IP 与清理过时设计文档
This commit is contained in:
MingNian
2026-06-28 22:53:35 +08:00
parent a748c316f2
commit 4507083f3f
20 changed files with 1936 additions and 4428 deletions

View File

@@ -0,0 +1,316 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart' show VoidCallback;
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/ble_device.dart';
import '../models/bp_reading.dart';
class UnsupportedBleDeviceException implements Exception {
final String message;
const UnsupportedBleDeviceException(this.message);
@override
String toString() => message;
}
class HealthBleService {
static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB';
static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB';
BluetoothDevice? _device;
StreamSubscription<BluetoothConnectionState>? _connStateSub;
StreamSubscription<List<int>>? _measurementSub;
final _connStateController = StreamController<bool>.broadcast();
Stream<bool> get connectionState => _connStateController.stream;
static bool isSupportedHealthDevice(ScanResult result) {
return BleDeviceIdentifier.isSupportedScanResult(result);
}
static BleDeviceType? deviceTypeFromScan(ScanResult result) {
return BleDeviceIdentifier.typeFromScan(result);
}
Future<BoundBleDevice> connectAndIdentify({
required BluetoothDevice device,
required String name,
}) async {
final services = await _connectAndDiscover(device);
final type = BleDeviceIdentifier.typeFromServices(services);
if (type == null) {
throw const UnsupportedBleDeviceException('暂不支持该设备');
}
return BoundBleDevice(
id: device.remoteId.toString(),
name: name,
type: type,
serviceUuid: type.serviceUuid,
);
}
Future<HealthBleSyncResult> syncBoundDevice(
BluetoothDevice device,
BoundBleDevice bound, {
Duration timeout = const Duration(seconds: 45),
VoidCallback? onConnected,
VoidCallback? onMeasurementReceived,
}) async {
final services = await _connectAndDiscover(device);
onConnected?.call();
final connectedType = BleDeviceIdentifier.typeFromServices(services);
if (connectedType != bound.type) {
throw const UnsupportedBleDeviceException('设备类型和绑定信息不一致');
}
return switch (bound.type) {
BleDeviceType.bloodPressure => BloodPressureSyncResult(
device: bound,
reading: await _syncBloodPressure(
services,
onMeasurementReceived: onMeasurementReceived,
).timeout(timeout),
),
BleDeviceType.glucose => throw const UnsupportedBleDeviceException(
'暂未支持血糖仪数据解析',
),
BleDeviceType.weightScale => throw const UnsupportedBleDeviceException(
'暂未支持体重秤数据解析',
),
BleDeviceType.pulseOximeter => throw const UnsupportedBleDeviceException(
'暂未支持血氧仪数据解析',
),
};
}
Future<List<BluetoothService>> _connectAndDiscover(
BluetoothDevice device,
) async {
await _measurementSub?.cancel();
_measurementSub = null;
await _connStateSub?.cancel();
_device = device;
_connStateSub = device.connectionState.listen((state) {
_connStateController.add(state == BluetoothConnectionState.connected);
});
if (device.isConnected) {
try {
await device.disconnect();
} catch (_) {}
}
await device.connect(
autoConnect: false,
timeout: const Duration(seconds: 10),
);
try {
await device.requestMtu(512);
} catch (_) {
// Some devices or platforms do not allow MTU negotiation.
}
final services = await device.discoverServices();
return services;
}
Future<BpReading> _syncBloodPressure(
List<BluetoothService> services, {
VoidCallback? onMeasurementReceived,
}) async {
BluetoothService? bpService;
for (final service in services) {
if (service.uuid.str128.toUpperCase() ==
BleDeviceIdentifier.bloodPressureServiceUuid) {
bpService = service;
break;
}
}
if (bpService == null) {
throw const UnsupportedBleDeviceException('未找到血压服务');
}
BluetoothCharacteristic? measurementChar;
BluetoothCharacteristic? racpChar;
for (final characteristic in bpService.characteristics) {
final uuid = characteristic.uuid.str128.toUpperCase();
if (uuid == bpMeasurementUuid) measurementChar = characteristic;
if (uuid == racpUuid) racpChar = characteristic;
}
if (measurementChar == null) {
throw const UnsupportedBleDeviceException('未找到血压测量特征');
}
final completer = Completer<BpReading>();
final readings = <BpReading>[];
Timer? settleTimer;
void acceptReading(List<int> raw) {
if (raw.isEmpty || completer.isCompleted) return;
try {
readings.add(_parseBloodPressureReading(raw));
onMeasurementReceived?.call();
settleTimer?.cancel();
settleTimer = Timer(const Duration(milliseconds: 900), () {
final reading = _selectCurrentBloodPressureReading(readings);
if (!completer.isCompleted) {
completer.complete(reading);
}
});
} catch (e, stack) {
if (!completer.isCompleted) completer.completeError(e, stack);
}
}
_measurementSub = measurementChar.onValueReceived.listen(
(raw) {
acceptReading(raw);
},
onError: (Object e, StackTrace stack) {
if (!completer.isCompleted) completer.completeError(e, stack);
},
);
await _enableMeasurementUpdates(measurementChar);
if (racpChar != null) {
try {
await racpChar.write([0x01, 0x01]);
} catch (_) {}
}
try {
return await completer.future;
} finally {
settleTimer?.cancel();
}
}
BpReading _selectCurrentBloodPressureReading(List<BpReading> readings) {
if (readings.isEmpty) {
throw const FormatException('未收到血压数据');
}
final now = DateTime.now();
final currentWindowStart = now.subtract(const Duration(minutes: 15));
final currentWindowEnd = now.add(const Duration(minutes: 2));
final timestampedCurrent = readings.where((reading) {
if (!reading.hasDeviceTimestamp) return false;
return !reading.timestamp.isBefore(currentWindowStart) &&
!reading.timestamp.isAfter(currentWindowEnd);
}).toList();
if (timestampedCurrent.isNotEmpty) {
timestampedCurrent.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return timestampedCurrent.first;
}
final timestamped = readings
.where((reading) => reading.hasDeviceTimestamp)
.toList();
if (timestamped.isNotEmpty) {
timestamped.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return timestamped.first;
}
return readings.last;
}
Future<void> _enableMeasurementUpdates(
BluetoothCharacteristic characteristic,
) async {
final useIndication = characteristic.properties.indicate;
await characteristic.setNotifyValue(true, forceIndications: useIndication);
}
Future<void> disconnect() async {
await _measurementSub?.cancel();
_measurementSub = null;
await _connStateSub?.cancel();
_connStateSub = null;
await _device?.disconnect();
_device = null;
_connStateController.add(false);
}
void dispose() {
unawaited(disconnect());
_connStateController.close();
}
BpReading _parseBloodPressureReading(List<int> raw) {
if (raw.length < 7) {
throw const FormatException('血压数据长度不足');
}
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();
if (isKpa) {
systolic = (systolic * 7.50062).round();
diastolic = (diastolic * 7.50062).round();
}
var offset = 7;
var timestamp = DateTime.now();
if (hasTimestamp && raw.length >= offset + 7) {
timestamp = DateTime(
raw[offset] | (raw[offset + 1] << 8),
raw[offset + 2],
raw[offset + 3],
raw[offset + 4],
raw[offset + 5],
raw[offset + 6],
);
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++;
}
var hasBodyMovement = false;
var hasIrregularPulse = false;
if (hasStatus && raw.length >= offset + 2) {
final status = raw[offset] | (raw[offset + 1] << 8);
hasBodyMovement = (status & 0x0001) != 0;
hasIrregularPulse = (status & 0x0800) != 0;
}
return BpReading(
systolic: systolic,
diastolic: diastolic,
pulse: pulse,
timestamp: timestamp,
userId: userId,
isKpa: isKpa,
hasBodyMovement: hasBodyMovement,
hasIrregularPulse: hasIrregularPulse,
hasDeviceTimestamp: hasTimestamp,
);
}
static double _decodeSFloat(int raw) {
var mantissa = raw & 0x0FFF;
if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000;
var exponent = (raw >> 12) & 0x0F;
if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0;
return (mantissa * pow(10, exponent)).toDouble();
}
}

View File

@@ -1,244 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/bp_reading.dart';
/// 欧姆龙蓝牙血压计 BLE 服务
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';
static const intermediateCuffUuid = '00002A36-0000-1000-8000-00805F9B34FB';
static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB';
BluetoothDevice? _device;
StreamSubscription<List<int>>? _measurementSub;
StreamSubscription<BluetoothConnectionState>? _connStateSub;
final _readingsController = StreamController<BpReading>.broadcast();
final _connStateController = StreamController<bool>.broadcast();
Stream<BpReading> get readings => _readingsController.stream;
Stream<bool> get connectionState => _connStateController.stream;
bool get isConnected => _device?.isConnected ?? false;
/// 直接重连已知设备通过MAC地址
Future<bool> reconnectByMac(String mac) async {
debugPrint('[BLE] 直连设备: $mac');
final bonded = await FlutterBluePlus.bondedDevices;
BluetoothDevice? target;
for (final d in bonded) {
if (d.remoteId.toString() == mac) {
target = d;
break;
}
}
if (target == null) {
debugPrint('[BLE] 未在已配对设备中找到 $mac');
return false;
}
try {
await connect(target);
return true;
} catch (e) {
debugPrint('[BLE] 直连失败: $e');
return false;
}
}
static bool isBpDevice(ScanResult r) {
final name = [
r.advertisementData.advName,
r.device.platformName,
].where((n) => n.isNotEmpty).join(' ').toUpperCase();
return name.contains('OMRON') ||
name.contains('HEM') ||
name.contains('BLESMART') ||
name.contains('J735') ||
name.contains('BPM') ||
name.contains('BP');
}
/// 连接设备并订阅测量数据
/// 设备连接后会自动发送存储的最后一次测量数据
Future<void> connect(BluetoothDevice device) async {
_device = device;
debugPrint('[BLE] ═══ 连接: "${device.platformName}" ═══');
_connStateSub = device.connectionState.listen((s) {
debugPrint('[BLE] 连接状态: $s');
_connStateController.add(s == BluetoothConnectionState.connected);
});
// 1. GATT连接
await device.connect(autoConnect: false);
debugPrint('[BLE] ① 已连接, MTU=${device.mtuNow}');
try { await device.requestMtu(512); } catch (_) {}
// 2. 发现服务
final services = await device.discoverServices();
debugPrint('[BLE] ② 发现${services.length}个服务');
// 遍历所有服务/特征
BluetoothService? bpSvc;
BluetoothCharacteristic? measChar, racpChar;
for (final s in services) {
debugPrint('[BLE] 服务: ${s.uuid.str128}');
for (final c in s.characteristics) {
final p = <String>[];
if (c.properties.read) p.add('R');
if (c.properties.write) p.add('W');
if (c.properties.notify) p.add('N');
if (c.properties.indicate) p.add('I');
debugPrint('[BLE] ${c.uuid.str128} [$p]');
for (final d in c.descriptors) {
debugPrint('[BLE] desc: ${d.uuid.str128}');
}
}
if (s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase()) bpSvc = s;
}
if (bpSvc == null) throw Exception('未找到血压服务0x1810');
for (final c in bpSvc.characteristics) {
final u = c.uuid.str128.toUpperCase();
if (u == bpMeasurementUuid.toUpperCase()) measChar = c;
if (u == racpUuid.toUpperCase()) racpChar = c;
}
if (measChar == null) throw Exception('未找到0x2A35');
debugPrint('[BLE] ③ 0x2A35: indicate=${measChar.properties.indicate} read=${measChar.properties.read}');
debugPrint('[BLE] RACP: ${racpChar != null ? "" : ""}');
// ★ 3. 先订阅数据流(在配置CCCD之前!)
debugPrint('[BLE] ④ 订阅lastValueStream...');
_measurementSub = measChar.lastValueStream.listen(
(raw) {
debugPrint('[BLE] ★★★ 收到数据! ${raw.length}字节 ★★★');
debugPrint('[BLE] HEX: ${raw.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
_parseReading(raw);
},
onError: (e) => debugPrint('[BLE] 流错误: $e'),
cancelOnError: false,
);
// 4. 配置CCCD
final isIndicate = measChar.properties.indicate;
final cccdVal = isIndicate ? [0x02, 0x00] : [0x01, 0x00];
debugPrint('[BLE] ⑤ 配置CCCD → ${isIndicate ? "Indicate" : "Notify"}...');
var ok = false;
for (final d in measChar.descriptors) {
if (d.uuid.str128 == '00002902-0000-1000-8000-00805F9B34FB') {
final before = await d.read();
await d.write(cccdVal);
final after = await d.read();
ok = after.length >= 2 && after[0] == cccdVal[0] && after[1] == cccdVal[1];
debugPrint('[BLE] ⑤ CCCD: $before$after ${ok ? "OK" : "FAIL"}');
break;
}
}
if (!ok) {
await measChar.setNotifyValue(true, forceIndications: isIndicate);
debugPrint('[BLE] ⑤ 降级setNotifyValue, isNotifying=${measChar.isNotifying}');
}
// 5. 主动读取缓存数据(设备存储的最后一次测量)
debugPrint('[BLE] ⑥ 主动读取0x2A35...');
if (measChar.properties.read) {
try {
final data = await measChar.read();
if (data.isNotEmpty) {
debugPrint('[BLE] ⑥ 读到${data.length}字节 HEX=${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
_parseReading(data);
} else {
debugPrint('[BLE] ⑥ 空数据');
}
} catch (e) {
debugPrint('[BLE] ⑥ 读取失败: $e');
}
}
// 6. RACP请求存储记录
if (racpChar != null) {
debugPrint('[BLE] ⑦ RACP请求存储记录...');
try {
await racpChar.write([0x01, 0x01]); // ReportStoredRecords, All
debugPrint('[BLE] ⑦ RACP已发送');
} catch (e) {
debugPrint('[BLE] ⑦ RACP失败: $e');
}
}
debugPrint('[BLE] ═══ 连接完成, 等待数据 ═══');
}
Future<void> disconnect() async {
_connStateSub?.cancel();
_connStateSub = null;
_measurementSub?.cancel();
_measurementSub = null;
await _device?.disconnect();
_device = null;
debugPrint('[BLE] 已断开');
}
void dispose() {
disconnect();
_readingsController.close();
_connStateController.close();
}
// ── 解析 BLE 测量数据 ──
void _parseReading(List<int> raw) {
debugPrint('[BLE] 解析: flags=0x${raw[0].toRadixString(16)}, len=${raw.length}');
if (raw.length < 7) { debugPrint('[BLE] 太短,跳过'); return; }
final flags = raw[0];
final isKpa = (flags & 0x01) != 0;
final hasTs = (flags & 0x02) != 0;
final hasPulse = (flags & 0x04) != 0;
final hasUid = (flags & 0x08) != 0;
final hasStatus = (flags & 0x10) != 0;
int sys = _decodeSFloat(raw[1] | (raw[2] << 8)).round();
int dia = _decodeSFloat(raw[3] | (raw[4] << 8)).round();
if (isKpa) { sys = (sys * 7.50062).round(); dia = (dia * 7.50062).round(); }
int off = 7;
DateTime ts = DateTime.now();
if (hasTs && raw.length >= off + 7) {
ts = DateTime(raw[off] | (raw[off + 1] << 8), raw[off + 2], raw[off + 3],
raw[off + 4], raw[off + 5], raw[off + 6]);
off += 7;
}
int? pulse;
if (hasPulse && raw.length >= off + 2) {
pulse = _decodeSFloat(raw[off] | (raw[off + 1] << 8)).round();
off += 2;
}
String? uid;
if (hasUid && raw.length > off) { uid = raw[off].toString(); off++; }
bool bm = false, ir = false;
if (hasStatus && raw.length >= off + 2) {
int s = raw[off] | (raw[off + 1] << 8);
bm = (s & 0x0001) != 0;
ir = (s & 0x0800) != 0;
}
final r = BpReading(systolic: sys, diastolic: dia, pulse: pulse, timestamp: ts,
userId: uid, isKpa: isKpa, hasBodyMovement: bm, hasIrregularPulse: ir);
debugPrint('[BLE] ★ $sys/$dia mmHg, pulse=$pulse');
_readingsController.add(r);
}
static double _decodeSFloat(int raw) {
int m = raw & 0x0FFF;
if (m & 0x0800 != 0) m |= 0xFFFFF000;
int e = (raw >> 12) & 0x0F;
if (e & 0x08 != 0) e |= 0xFFFFFFF0;
return (m * pow(10, e)).toDouble();
}
}