Commit 531a4d4e authored by akari's avatar akari

feat: 添加蓝牙,MQTT连接

parent b56b4a34
# FVM Version Cache
.fvm/
.DS_Store
# claude code
.claude/
......@@ -9,6 +10,9 @@
.dart_tool/
build/
.flutter-plugins
.flutter-plugins-dependencies
third_party/
pubspec.lock
......
{
"dart.flutterSdkPath": ".fvm/versions/3.41.9",
"java.configuration.updateBuildConfiguration": "disabled"
}
\ No newline at end of file
"java.configuration.updateBuildConfiguration": "disabled",
"files.exclude": {
"**/.dart_tool": true,
"**/.gradle": true,
"**/.idea": true,
"**/build": true,
"**/third_party": true,
"**/*.g.dart": true,
"**/*.gr.dart": true,
"**/*.freezed.dart": true,
"**/*.mocks.dart": true
},
"search.exclude": {
"**/.dart_tool": true,
"**/.gradle": true,
"**/.idea": true,
"**/build": true,
"**/third_party": true,
"**/*.g.dart": true,
"**/*.gr.dart": true,
"**/*.freezed.dart": true,
"**/*.mocks.dart": true
},
"files.watcherExclude": {
"**/.dart_tool/**": true,
"**/.gradle/**": true,
"**/.idea/**": true,
"**/build/**": true,
"**/third_party/**": true
}
}
......@@ -9,6 +9,16 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.gr.dart"
- "**/*.freezed.dart"
- "**/*.mocks.dart"
- ".dart_tool/**"
- "build/**"
- "third_party/**"
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
......
......@@ -4,6 +4,14 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- BLE 蓝牙扫描/连接权限 -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- USB 摄像头权限 -->
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<uses-permission android:name="android.permission.CAMERA" />
......
org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk-22.0.1.jdk/Contents/Home
org.gradle.java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
......@@ -11,6 +11,9 @@ class UserModel extends Equatable {
final dynamic hospitalId;
final String? hospitalName;
// ignore: non_constant_identifier_names
static String? MAC;
const UserModel({
required this.id,
this.nickname,
......
import 'dart:async';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
typedef BluetoothPayloadParser<T> = T? Function(List<int> bytes, String rawHex);
enum BluetoothConnectionStatus {
disconnected,
connecting,
connectedReady,
active,
}
class BluetoothScanDevice {
const BluetoothScanDevice({
required this.device,
required this.remoteId,
required this.name,
required this.hasName,
required this.rssi,
});
final DiscoveredDevice device;
final String remoteId;
final String name;
final bool hasName;
final int rssi;
}
class BluetoothDataPacket<T> {
const BluetoothDataPacket({
required this.deviceId,
required this.rawHex,
required this.bytes,
this.parsedData,
});
final String deviceId;
final String rawHex;
final List<int> bytes;
final T? parsedData;
}
class BleBluetoothManager<T> {
BleBluetoothManager({
BluetoothPayloadParser<T>? parser,
FlutterReactiveBle? ble,
Uuid? serviceUuid,
Uuid? notifyCharacteristicUuid,
Uuid? writeCharacteristicUuid,
this.connectionTimeout = const Duration(seconds: 15),
}) : _parser = parser,
_ble = ble ?? FlutterReactiveBle(),
serviceUuid =
serviceUuid ?? Uuid.parse('0000FFE0-0000-1000-8000-00805F9B34FB'),
notifyCharacteristicUuid = notifyCharacteristicUuid ??
Uuid.parse('0000FFE4-0000-1000-8000-00805F9B34FB'),
writeCharacteristicUuid = writeCharacteristicUuid ??
Uuid.parse('0000FFE9-0000-1000-8000-00805F9B34FB');
final BluetoothPayloadParser<T>? _parser;
final FlutterReactiveBle _ble;
final Uuid serviceUuid;
final Uuid notifyCharacteristicUuid;
final Uuid writeCharacteristicUuid;
final Duration connectionTimeout;
final _scanDevicesController =
StreamController<List<BluetoothScanDevice>>.broadcast();
final _dataController = StreamController<BluetoothDataPacket<T>>.broadcast();
final _stateController =
StreamController<BluetoothConnectionStatus>.broadcast();
final _messageController = StreamController<String>.broadcast();
final _sendResultController = StreamController<bool>.broadcast();
Stream<List<BluetoothScanDevice>> get scanDevicesStream =>
_scanDevicesController.stream;
Stream<BluetoothDataPacket<T>> get dataStream => _dataController.stream;
Stream<BluetoothConnectionStatus> get stateStream => _stateController.stream;
Stream<String> get messageStream => _messageController.stream;
Stream<bool> get sendResultStream => _sendResultController.stream;
BluetoothConnectionStatus _status = BluetoothConnectionStatus.disconnected;
String? _connectedDeviceId;
QualifiedCharacteristic? _notifyCharacteristic;
QualifiedCharacteristic? _writeCharacteristic;
StreamSubscription<DiscoveredDevice>? _scanSubscription;
StreamSubscription<ConnectionStateUpdate>? _connectionSubscription;
StreamSubscription<List<int>>? _notifySubscription;
Timer? _scanTimer;
final Map<String, BluetoothScanDevice> _scannedDevices = {};
BluetoothConnectionStatus get status => _status;
String? get connectedDeviceId => _connectedDeviceId;
bool get isConnected =>
_status == BluetoothConnectionStatus.connectedReady ||
_status == BluetoothConnectionStatus.active;
bool get isActive => _status == BluetoothConnectionStatus.active;
List<BluetoothScanDevice> get scannedDevices => List.unmodifiable(
_scannedDevices.values.toList()
..sort(
(left, right) {
if (left.hasName != right.hasName) {
return left.hasName ? -1 : 1;
}
if (left.hasName && right.hasName) {
final nameCompare = left.name.toUpperCase().compareTo(
right.name.toUpperCase(),
);
if (nameCompare != 0) {
return nameCompare;
}
}
return _normalizeRemoteIdForSort(
left.remoteId,
).compareTo(_normalizeRemoteIdForSort(right.remoteId));
},
),
);
Future<bool> ensureBluetoothReady() async {
await _ble.initialize();
final status = _ble.status;
if (status == BleStatus.ready) {
return true;
}
final nextStatus = await _ble.statusStream
.where((value) => value != BleStatus.unknown)
.first
.timeout(const Duration(seconds: 2), onTimeout: () => status);
if (nextStatus != BleStatus.ready) {
_notifyMessage('蓝牙未就绪或未授权: $nextStatus');
return false;
}
return true;
}
Future<void> scanForDevices({
required List<String> namePrefixes,
Duration timeout = const Duration(seconds: 10),
}) async {
if (!await ensureBluetoothReady()) {
return;
}
await stopScan();
_scannedDevices.clear();
_scanDevicesController.add(scannedDevices);
_notifyMessage(
namePrefixes.isEmpty ? '开始扫描全部蓝牙设备' : '开始扫描蓝牙设备: $namePrefixes');
_scanSubscription = _ble.scanForDevices(
withServices: const [],
scanMode: ScanMode.lowLatency,
requireLocationServicesEnabled: false,
).listen(
(device) {
if (namePrefixes.isNotEmpty &&
!_startsWithAny(device.name, namePrefixes)) {
return;
}
final deviceName = device.name.trim();
_scannedDevices[device.id] = BluetoothScanDevice(
device: device,
remoteId: device.id,
name: deviceName.isEmpty ? '未知设备' : deviceName,
hasName: deviceName.isNotEmpty,
rssi: device.rssi,
);
_scanDevicesController.add(scannedDevices);
},
onError: (Object error) => _notifyMessage('扫描失败: $error'),
);
await Future<void>.delayed(timeout);
await stopScan();
_scanDevicesController.add(scannedDevices);
_notifyMessage('蓝牙扫描结束,共发现 ${_scannedDevices.length} 个设备');
}
Future<void> prepareConnection(String targetName) async {
if (targetName.isEmpty) {
_notifyMessage('无效的蓝牙设备名称');
return;
}
if (_status != BluetoothConnectionStatus.disconnected) {
_notifyMessage('蓝牙已在连接或已连接状态,跳过连接');
return;
}
if (!await ensureBluetoothReady()) {
return;
}
_setStatus(BluetoothConnectionStatus.connecting);
await stopScan();
_notifyMessage('开始扫描目标蓝牙设备: $targetName');
var found = false;
_scanSubscription = _ble.scanForDevices(
withServices: const [],
scanMode: ScanMode.lowLatency,
requireLocationServicesEnabled: false,
).listen(
(device) async {
if (found || !device.name.contains(targetName)) {
return;
}
found = true;
await stopScan();
await connectToDeviceId(device.id);
},
onError: (Object error) {
_setStatus(BluetoothConnectionStatus.disconnected);
_notifyMessage('扫描目标设备失败: $error');
},
);
_scanTimer = Timer(connectionTimeout, () async {
if (!found && _status == BluetoothConnectionStatus.connecting) {
await stopScan();
_setStatus(BluetoothConnectionStatus.disconnected);
_notifyMessage('未找到目标蓝牙设备: $targetName');
}
});
}
Future<void> connectByRemoteId(String remoteId) {
return connectToDeviceId(remoteId);
}
Future<void> connectToDevice(BluetoothScanDevice device) {
return connectToDeviceId(device.remoteId);
}
Future<void> connectToDeviceId(String deviceId) async {
if (deviceId.isEmpty) {
_notifyMessage('无效的蓝牙设备地址');
return;
}
await disconnect();
_connectedDeviceId = deviceId;
_notifyCharacteristic = QualifiedCharacteristic(
deviceId: deviceId,
serviceId: serviceUuid,
characteristicId: notifyCharacteristicUuid,
);
_writeCharacteristic = QualifiedCharacteristic(
deviceId: deviceId,
serviceId: serviceUuid,
characteristicId: writeCharacteristicUuid,
);
_setStatus(BluetoothConnectionStatus.connecting);
_notifyMessage('正在连接蓝牙设备: $deviceId');
final completer = Completer<void>();
_connectionSubscription = _ble
.connectToDevice(
id: deviceId,
servicesWithCharacteristicsToDiscover: {
serviceUuid: [notifyCharacteristicUuid, writeCharacteristicUuid],
},
connectionTimeout: connectionTimeout,
)
.listen(
(update) async {
switch (update.connectionState) {
case DeviceConnectionState.connecting:
_setStatus(BluetoothConnectionStatus.connecting);
break;
case DeviceConnectionState.connected:
_setStatus(BluetoothConnectionStatus.connectedReady);
_notifyMessage('蓝牙连接成功: $deviceId');
await _enableNotifications(deviceId);
activateDataSource();
if (!completer.isCompleted) {
completer.complete();
}
break;
case DeviceConnectionState.disconnecting:
_setStatus(BluetoothConnectionStatus.disconnected);
break;
case DeviceConnectionState.disconnected:
_setStatus(BluetoothConnectionStatus.disconnected);
_notifyMessage('蓝牙连接断开: $deviceId');
_clearCharacteristics();
if (!completer.isCompleted && update.failure != null) {
completer.completeError(update.failure!);
}
break;
}
},
onError: (Object error) {
_setStatus(BluetoothConnectionStatus.disconnected);
_notifyMessage('蓝牙连接失败: $error');
if (!completer.isCompleted) {
completer.completeError(error);
}
},
);
try {
await completer.future.timeout(connectionTimeout);
} catch (_) {
await disconnect();
rethrow;
}
}
void activateDataSource() {
if (_status == BluetoothConnectionStatus.connectedReady ||
_status == BluetoothConnectionStatus.active) {
_setStatus(BluetoothConnectionStatus.active);
_notifyMessage('蓝牙数据源已激活');
}
}
Future<void> writeData(List<int> data, {bool withoutResponse = false}) async {
if (data.isEmpty) {
_notifyMessage('发送数据为空');
_sendResultController.add(false);
return;
}
final characteristic = _writeCharacteristic;
if (!isConnected || characteristic == null) {
_notifyMessage('蓝牙未连接或未找到发送特征值,无法发送数据');
_sendResultController.add(false);
return;
}
try {
if (withoutResponse) {
await _ble.writeCharacteristicWithoutResponse(
characteristic,
value: data,
);
} else {
await _ble.writeCharacteristicWithResponse(characteristic, value: data);
}
_notifyMessage('蓝牙数据发送成功: ${bytesToHex(data)}');
_sendResultController.add(true);
} catch (error) {
_notifyMessage('蓝牙数据发送失败: $error');
_sendResultController.add(false);
}
}
Future<void> writeHex(String hex, {bool withoutResponse = false}) {
return writeData(hexToBytes(hex), withoutResponse: withoutResponse);
}
Future<void> stopScan() async {
_scanTimer?.cancel();
_scanTimer = null;
await _scanSubscription?.cancel();
_scanSubscription = null;
}
Future<void> disconnect() async {
await stopScan();
await _notifySubscription?.cancel();
_notifySubscription = null;
await _connectionSubscription?.cancel();
_connectionSubscription = null;
_connectedDeviceId = null;
_clearCharacteristics();
_setStatus(BluetoothConnectionStatus.disconnected);
}
Future<void> release() async {
await disconnect();
await _scanDevicesController.close();
await _dataController.close();
await _stateController.close();
await _messageController.close();
await _sendResultController.close();
}
Future<void> _enableNotifications(String deviceId) async {
final characteristic = _notifyCharacteristic;
if (characteristic == null) {
throw StateError('接收特征值为空');
}
await _notifySubscription?.cancel();
_notifySubscription = _ble.subscribeToCharacteristic(characteristic).listen(
(bytes) {
if (!isActive || bytes.isEmpty) {
return;
}
final rawHex = bytesToHex(bytes);
final parsedData = _parser?.call(bytes, rawHex);
_dataController.add(
BluetoothDataPacket<T>(
deviceId: deviceId,
rawHex: rawHex,
bytes: List.unmodifiable(bytes),
parsedData: parsedData,
),
);
},
onError: (Object error) => _notifyMessage('接收蓝牙数据失败: $error'),
);
}
void _clearCharacteristics() {
_notifyCharacteristic = null;
_writeCharacteristic = null;
}
void _setStatus(BluetoothConnectionStatus status) {
_status = status;
if (!_stateController.isClosed) {
_stateController.add(status);
}
}
void _notifyMessage(String message) {
if (!_messageController.isClosed) {
_messageController.add(message);
}
}
static String bytesToHex(List<int> bytes) {
return bytes
.map((item) => item.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
static List<int> hexToBytes(String hex) {
final clean = hex.replaceAll(RegExp(r'\s+'), '');
if (clean.length.isOdd) {
throw FormatException('Hex 字符串长度必须为偶数: $hex');
}
return [
for (var index = 0; index < clean.length; index += 2)
int.parse(clean.substring(index, index + 2), radix: 16),
];
}
static bool _startsWithAny(String value, List<String> prefixes) {
return prefixes.any(value.startsWith);
}
static String _normalizeRemoteIdForSort(String remoteId) {
return remoteId.replaceAll(RegExp(r'[^0-9a-zA-Z]'), '').toUpperCase();
}
}
export 'bluetooth_manager.dart';
export 'mcu_report.dart';
import 'dart:convert';
const int mcuReportBytes = 145;
class HexReader {
HexReader(String hexData) {
final cleanHex = hexData.replaceAll(RegExp(r'\s+'), '').toUpperCase();
if (cleanHex.isEmpty ||
cleanHex.length.isOdd ||
!RegExp(r'^[0-9A-F]+$').hasMatch(cleanHex)) {
throw const FormatException('数据必须是长度为偶数的十六进制字符串');
}
data = cleanHex;
}
late final String data;
int index = 0;
int get byteIndex => index ~/ 2;
int get remainingBytes => (data.length - index) ~/ 2;
String readHex(int bytes) {
_ensureAvailable(bytes);
final length = bytes * 2;
final value = data.substring(index, index + length);
index += length;
return value;
}
int readUInt8() {
return int.parse(readHex(1), radix: 16);
}
int readUInt16() {
return int.parse(readHex(2), radix: 16);
}
double readTenths() {
return readUInt16() / 10;
}
int readHighByte() {
return readUInt16() >> 8;
}
String readAscii(int bytes) {
final hex = readHex(bytes);
final values = <int>[];
for (var index = 0; index < hex.length; index += 2) {
values.add(int.parse(hex.substring(index, index + 2), radix: 16));
}
return utf8.decode(values, allowMalformed: true).split('\x00').first.trim();
}
void _ensureAvailable(int bytes) {
if (bytes < 0) {
throw ArgumentError.value(bytes, 'bytes', '字节数必须是非负整数');
}
if (remainingBytes < bytes) {
throw RangeError('数据不足:需要 $bytes 字节,只剩 $remainingBytes 字节');
}
}
}
class McuReport {
const McuReport({
required this.head,
required this.direct,
required this.serviceNum,
required this.length,
required this.mainTemperature,
required this.versionNumber,
required this.ambientTemperature,
required this.upperTemperature,
required this.humidity,
required this.oxygenConcentration,
required this.temperatureSetting,
required this.lowerTemperature,
required this.humiditySetting,
required this.oxygenSetting,
required this.internalExternalCycleState,
required this.negativeIonSwitch,
required this.uvLamp,
required this.inspectionLamp,
required this.floodLight,
required this.o2ChangeSlope,
required this.rightAtomizerTreatmentTime,
required this.rightInfraredPhysiotherapyTime,
required this.tempS1,
required this.tempH1,
required this.atomizerTreatmentTime,
required this.infraredPhysiotherapyTime,
required this.mainTempCorrect,
required this.auxiliaryTemperatureCorrect,
required this.o2CorrectStart,
required this.o2CorrectDiffer,
required this.humidityCorrect,
required this.oxygenCorrect,
required this.eTempCorrect,
required this.xyyModel,
required this.unknown,
required this.wifible,
required this.levelLight,
required this.openO2,
required this.airConditioner,
required this.co2Correct,
required this.co2Measurement,
required this.co2Setting,
required this.co2FastClearSwitch,
required this.openO2SettingMax,
required this.noOpenO2SettingMax,
required this.windSet,
required this.rangeData1,
required this.rangeData2,
required this.rangeData3,
required this.co2Safety,
required this.co2SettingMin,
required this.blueLight,
required this.redLight,
required this.fcSwitch,
required this.o2SupplyTime,
required this.totalTime,
required this.ratioTemp2,
required this.ratioTemp1,
required this.sp,
required this.dp,
required this.mean,
required this.pulseRate,
required this.xyyStatus,
required this.xyyError1,
required this.sn,
required this.topTempCorrect,
required this.midTempCorrect,
required this.o2OpenCorrectStart,
required this.o2OpenCorrectStep,
required this.o2CorrectMax,
required this.o2OpenCorrectMax,
required this.parsedBytes,
});
final String head;
final String direct;
final String serviceNum;
final int length;
final double mainTemperature;
final double versionNumber;
final double ambientTemperature;
final double upperTemperature;
final int humidity;
final int oxygenConcentration;
final double temperatureSetting;
final double lowerTemperature;
final int humiditySetting;
final int oxygenSetting;
final int internalExternalCycleState;
final int negativeIonSwitch;
final int uvLamp;
final int inspectionLamp;
final int floodLight;
final int o2ChangeSlope;
final int rightAtomizerTreatmentTime;
final int rightInfraredPhysiotherapyTime;
final double tempS1;
final int tempH1;
final int atomizerTreatmentTime;
final int infraredPhysiotherapyTime;
final int mainTempCorrect;
final int auxiliaryTemperatureCorrect;
final int o2CorrectStart;
final int o2CorrectDiffer;
final int humidityCorrect;
final int oxygenCorrect;
final int eTempCorrect;
final String xyyModel;
final String unknown;
final int wifible;
final int levelLight;
final int openO2;
final int airConditioner;
final int co2Correct;
final int co2Measurement;
final int co2Setting;
final int co2FastClearSwitch;
final int openO2SettingMax;
final int noOpenO2SettingMax;
final int windSet;
final String rangeData1;
final String rangeData2;
final String rangeData3;
final int co2Safety;
final int co2SettingMin;
final int blueLight;
final int redLight;
final int fcSwitch;
final int o2SupplyTime;
final int totalTime;
final int ratioTemp2;
final int ratioTemp1;
final int sp;
final int dp;
final int mean;
final int pulseRate;
final int xyyStatus;
final int xyyError1;
final String sn;
final int topTempCorrect;
final int midTempCorrect;
final int o2OpenCorrectStart;
final int o2OpenCorrectStep;
final int o2CorrectMax;
final int o2OpenCorrectMax;
final int parsedBytes;
Map<String, dynamic> toMap() {
return {
'head': head,
'direct': direct,
'serviceNum': serviceNum,
'length': length,
'mainTemperature': mainTemperature,
'versionNumber': versionNumber,
'ambientTemperature': ambientTemperature,
'upperTemperature': upperTemperature,
'humidity': humidity,
'oxygenConcentration': oxygenConcentration,
'temperatureSetting': temperatureSetting,
'lowerTemperature': lowerTemperature,
'humiditySetting': humiditySetting,
'oxygenSetting': oxygenSetting,
'internalExternalCycleState': internalExternalCycleState,
'negativeIonSwitch': negativeIonSwitch,
'uvLamp': uvLamp,
'inspectionLamp': inspectionLamp,
'floodLight': floodLight,
'o2ChangeSlope': o2ChangeSlope,
'rightAtomizerTreatmentTime': rightAtomizerTreatmentTime,
'rightInfraredPhysiotherapyTime': rightInfraredPhysiotherapyTime,
'tempS1': tempS1,
'tempH1': tempH1,
'atomizerTreatmentTime': atomizerTreatmentTime,
'infraredPhysiotherapyTime': infraredPhysiotherapyTime,
'mainTempCorrect': mainTempCorrect,
'auxiliaryTemperatureCorrect': auxiliaryTemperatureCorrect,
'o2CorrectStart': o2CorrectStart,
'o2CorrectDiffer': o2CorrectDiffer,
'humidityCorrect': humidityCorrect,
'oxygenCorrect': oxygenCorrect,
'eTempCorrect': eTempCorrect,
'xyyModel': xyyModel,
'unknown': unknown,
'wifible': wifible,
'levelLight': levelLight,
'openO2': openO2,
'airConditioner': airConditioner,
'co2Correct': co2Correct,
'co2Measurement': co2Measurement,
'co2Setting': co2Setting,
'co2FastClearSwitch': co2FastClearSwitch,
'openO2SettingMax': openO2SettingMax,
'noOpenO2SettingMax': noOpenO2SettingMax,
'windSet': windSet,
'rangeData1': rangeData1,
'rangeData2': rangeData2,
'rangeData3': rangeData3,
'co2Safety': co2Safety,
'co2SettingMin': co2SettingMin,
'blueLight': blueLight,
'redLight': redLight,
'fcSwitch': fcSwitch,
'o2SupplyTime': o2SupplyTime,
'totalTime': totalTime,
'ratioTemp2': ratioTemp2,
'ratioTemp1': ratioTemp1,
'sp': sp,
'dp': dp,
'mean': mean,
'pulseRate': pulseRate,
'xyyStatus': xyyStatus,
'xyyError1': xyyError1,
'sn': sn,
'topTempCorrect': topTempCorrect,
'midTempCorrect': midTempCorrect,
'o2OpenCorrectStart': o2OpenCorrectStart,
'o2OpenCorrectStep': o2OpenCorrectStep,
'o2CorrectMax': o2CorrectMax,
'o2OpenCorrectMax': o2OpenCorrectMax,
'parsedBytes': parsedBytes,
};
}
Map<String, String> toMonitorPayload({DateTime? date}) {
return {
'sn': sn,
'date': '${(date ?? DateTime.now()).millisecondsSinceEpoch}',
'mainTemp': mainTemperature.toStringAsFixed(1),
'humidity': '$humidity',
'oxygenConcentration': '$oxygenConcentration',
'co2Measurement': '$co2Measurement',
'mainTempSetting': temperatureSetting.toStringAsFixed(1),
'humiditySetting': '$humiditySetting',
'oxygenConcentrationSetting': '$oxygenSetting',
'co2Setting': '$co2Setting',
'windSet': '$windSet',
'cycleState': '$internalExternalCycleState',
'levelLight': '$levelLight',
'openO2': '$openO2',
'floodlight': '$floodLight',
'inspectionLamp': '$inspectionLamp',
'blueLight': '$blueLight',
'redLight': '$redLight',
'airConditioner': '$airConditioner',
'wifible': '$wifible',
'co2FastClearSwitch': '$co2FastClearSwitch',
'rightAtomizerTreatmentTime': '$rightAtomizerTreatmentTime',
'rightInfraredPhysiotherapyTime': '$rightInfraredPhysiotherapyTime',
'totalTime': '$totalTime',
'o2SupplyTime': '$o2SupplyTime',
};
}
@override
String toString() => toMap().toString();
}
class McuReportParser {
static McuReport parseHex(String hexData) {
final data = HexReader(hexData);
if (data.remainingBytes < mcuReportBytes) {
throw RangeError(
'完整 MCU 上报需要 $mcuReportBytes 字节,当前只有 ${data.remainingBytes} 字节',
);
}
return McuReport(
head: data.readHex(2),
direct: data.readHex(1),
serviceNum: data.readHex(1),
length: data.readUInt8(),
mainTemperature: data.readTenths(),
versionNumber: data.readTenths(),
ambientTemperature: data.readTenths(),
upperTemperature: data.readTenths(),
humidity: data.readUInt16(),
oxygenConcentration: data.readUInt16(),
temperatureSetting: data.readTenths(),
lowerTemperature: data.readTenths(),
humiditySetting: data.readUInt16(),
oxygenSetting: data.readUInt16(),
internalExternalCycleState: data.readUInt16(),
negativeIonSwitch: data.readUInt16(),
uvLamp: data.readUInt16(),
inspectionLamp: data.readUInt16(),
floodLight: data.readUInt16(),
o2ChangeSlope: data.readUInt16(),
rightAtomizerTreatmentTime: data.readUInt16(),
rightInfraredPhysiotherapyTime: data.readUInt16(),
tempS1: data.readHighByte() / 10,
tempH1: data.readHighByte(),
atomizerTreatmentTime: data.readUInt16(),
infraredPhysiotherapyTime: data.readHighByte(),
mainTempCorrect: data.readHighByte(),
auxiliaryTemperatureCorrect: data.readHighByte(),
o2CorrectStart: data.readHighByte(),
o2CorrectDiffer: data.readHighByte(),
humidityCorrect: data.readHighByte(),
oxygenCorrect: data.readHighByte(),
eTempCorrect: data.readHighByte(),
xyyModel: data.readHex(2),
unknown: data.readHex(2),
wifible: data.readHighByte(),
levelLight: data.readHighByte(),
openO2: data.readHighByte(),
airConditioner: data.readHighByte(),
co2Correct: data.readHighByte(),
co2Measurement: data.readUInt16(),
co2Setting: data.readUInt16(),
co2FastClearSwitch: data.readHighByte(),
openO2SettingMax: data.readHighByte(),
noOpenO2SettingMax: data.readHighByte(),
windSet: data.readHighByte(),
rangeData1: data.readHex(2),
rangeData2: data.readHex(2),
rangeData3: data.readHex(2),
co2Safety: data.readUInt16(),
co2SettingMin: data.readUInt16(),
blueLight: data.readHighByte(),
redLight: data.readHighByte(),
fcSwitch: data.readHighByte(),
o2SupplyTime: data.readUInt16(),
totalTime: data.readUInt16(),
ratioTemp2: data.readUInt8(),
ratioTemp1: data.readUInt8(),
sp: data.readHighByte(),
dp: data.readHighByte(),
mean: data.readHighByte(),
pulseRate: data.readHighByte(),
xyyStatus: data.readHighByte(),
xyyError1: data.readHighByte(),
sn: data.readAscii(10),
topTempCorrect: data.readHighByte(),
midTempCorrect: data.readHighByte(),
o2OpenCorrectStart: data.readHighByte(),
o2OpenCorrectStep: data.readHighByte(),
o2CorrectMax: data.readHighByte(),
o2OpenCorrectMax: data.readHighByte(),
parsedBytes: data.byteIndex,
);
}
static McuReport parseBytes(List<int> bytes) {
return parseHex(_bytesToHex(bytes));
}
static String _bytesToHex(List<int> bytes) {
return bytes
.map((item) => item.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
}
class McuReportFrameDecoder {
final List<int> _buffer = [];
List<McuReport> addBytes(List<int> bytes) {
if (bytes.isEmpty) {
return const [];
}
_buffer.addAll(bytes);
final reports = <McuReport>[];
while (_buffer.length >= mcuReportBytes) {
final headIndex = _buffer.indexOf(0x68);
if (headIndex < 0) {
_buffer.clear();
break;
}
if (headIndex > 0) {
_buffer.removeRange(0, headIndex);
}
if (_buffer.length < mcuReportBytes) {
break;
}
final frame = List<int>.from(_buffer.take(mcuReportBytes));
if (frame.last != 0x16) {
_buffer.removeAt(0);
continue;
}
reports.add(McuReportParser.parseBytes(frame));
_buffer.removeRange(0, mcuReportBytes);
}
return reports;
}
void clear() {
_buffer.clear();
}
}
export 'laki_mqtt_client.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:laki_icu_app/utils/bluetooth/index.dart';
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';
enum LakiMqttConnectionState {
disconnected,
connecting,
connected,
failed,
}
class LakiMqttConfig {
const LakiMqttConfig({
required this.host,
required this.clientId,
this.port = 1883,
this.username,
this.password,
this.useSsl = false,
this.useWebSocket = false,
this.webSocketProtocols,
this.keepAliveSeconds = 30,
this.connectTimeoutMs = 5000,
this.logging = false,
});
/// TODO: 替换为腾讯云 IoT MQTT broker 地址、端口、clientId 和鉴权信息。
const LakiMqttConfig.placeholder()
: host = '',
port = 1883,
clientId = '',
username = null,
password = null,
useSsl = false,
useWebSocket = false,
webSocketProtocols = null,
keepAliveSeconds = 30,
connectTimeoutMs = 5000,
logging = false;
final String host;
final int port;
final String clientId;
final String? username;
final String? password;
final bool useSsl;
final bool useWebSocket;
final List<String>? webSocketProtocols;
final int keepAliveSeconds;
final int connectTimeoutMs;
final bool logging;
bool get isConfigured => host.isNotEmpty && clientId.isNotEmpty;
}
class LakiMqttTopics {
const LakiMqttTopics._();
static String icuUp(String deviceSn) => 'icu/$deviceSn/up';
static String icuTo(String deviceSn) => 'icu/$deviceSn/to';
static String icuPetBind(String deviceSn) => 'icu/$deviceSn/pet/bind';
static String cameraTo(String cabinSn) => 'camera/$cabinSn/to';
static const String clientConnected = r'$events/client_connected';
static const String clientDisconnected = r'$events/client_disconnected';
static const String messageDelivered = r'$events/message_delivered';
}
class LakiMqttMessage {
const LakiMqttMessage({
required this.topic,
required this.payload,
this.json,
});
final String topic;
final String payload;
final Map<String, dynamic>? json;
}
class LakiMqttClient {
LakiMqttClient({required LakiMqttConfig config}) : _config = config;
final LakiMqttConfig _config;
MqttServerClient? _client;
StreamSubscription<List<MqttReceivedMessage<MqttMessage>>>? _updatesSub;
final _stateController =
StreamController<LakiMqttConnectionState>.broadcast();
final _messageController = StreamController<LakiMqttMessage>.broadcast();
final _errorController = StreamController<String>.broadcast();
Stream<LakiMqttConnectionState> get stateStream => _stateController.stream;
Stream<LakiMqttMessage> get messageStream => _messageController.stream;
Stream<String> get errorStream => _errorController.stream;
bool get isConnected =>
_client?.connectionStatus?.state == MqttConnectionState.connected;
Future<void> connect() async {
if (!_config.isConfigured) {
throw StateError('MQTT 连接配置未填写,请先配置 host 和 clientId');
}
if (isConnected) {
return;
}
_emitState(LakiMqttConnectionState.connecting);
final client = MqttServerClient(_config.host, _config.clientId);
client.port = _config.port;
client.secure = _config.useSsl;
client.useWebSocket = _config.useWebSocket;
client.keepAlivePeriod = _config.keepAliveSeconds;
client.connectTimeoutPeriod = _config.connectTimeoutMs;
client.onConnected = () => _emitState(LakiMqttConnectionState.connected);
client.onDisconnected =
() => _emitState(LakiMqttConnectionState.disconnected);
client.onSubscribed = (topic) => _emitError('MQTT 已订阅: $topic');
client.onSubscribeFail = (topic) => _emitError('MQTT 订阅失败: $topic');
client.pongCallback = () {};
final webSocketProtocols = _config.webSocketProtocols;
if (webSocketProtocols != null) {
client.websocketProtocols = webSocketProtocols;
}
client.logging(on: _config.logging);
client.setProtocolV311();
client.connectionMessage = MqttConnectMessage()
.withClientIdentifier(_config.clientId)
.startClean()
.withWillQos(MqttQos.atLeastOnce);
try {
await client.connect(_config.username, _config.password);
} on NoConnectionException catch (error) {
client.disconnect();
_emitState(LakiMqttConnectionState.failed);
_emitError('MQTT 连接失败: $error');
rethrow;
} on SocketException catch (error) {
client.disconnect();
_emitState(LakiMqttConnectionState.failed);
_emitError('MQTT Socket 连接失败: $error');
rethrow;
}
if (client.connectionStatus?.state != MqttConnectionState.connected) {
final status = client.connectionStatus;
client.disconnect();
_emitState(LakiMqttConnectionState.failed);
throw StateError('MQTT 连接未成功: $status');
}
_client = client;
_listenUpdates(client);
_emitState(LakiMqttConnectionState.connected);
}
void subscribe(String topic, {MqttQos qos = MqttQos.atLeastOnce}) {
_requireConnected().subscribe(topic, qos);
}
void unsubscribe(String topic) {
_requireConnected().unsubscribe(topic);
}
void subscribeIcuCommands(String deviceSn) {
subscribe(LakiMqttTopics.icuTo(deviceSn));
}
void subscribePetBind(String deviceSn) {
subscribe(LakiMqttTopics.icuPetBind(deviceSn));
}
int publishJson(
String topic,
Map<String, dynamic> payload, {
MqttQos qos = MqttQos.atLeastOnce,
bool retain = false,
}) {
return publishString(
topic,
jsonEncode(payload),
qos: qos,
retain: retain,
);
}
int publishString(
String topic,
String payload, {
MqttQos qos = MqttQos.atLeastOnce,
bool retain = false,
}) {
final builder = MqttClientPayloadBuilder()..addString(payload);
return _requireConnected().publishMessage(
topic,
qos,
builder.payload!,
retain: retain,
);
}
int publishMonitorReport(
McuReport report, {
DateTime? date,
MqttQos qos = MqttQos.atLeastOnce,
}) {
if (report.sn.isEmpty) {
throw ArgumentError.value(report.sn, 'report.sn', '设备 SN 不能为空');
}
return publishJson(
LakiMqttTopics.icuUp(report.sn),
report.toMonitorPayload(date: date),
qos: qos,
);
}
int publishCommandResponse({
required String deviceSn,
required String eventId,
required String refEventId,
required int status,
required String message,
MqttQos qos = MqttQos.atLeastOnce,
}) {
return publishJson(
LakiMqttTopics.icuUp(deviceSn),
{
'eventId': eventId,
'refEventId': refEventId,
'deviceType': 'ICU',
'timestamp': DateTime.now().millisecondsSinceEpoch,
'type': 'cmdResponse',
'data': {
'status': status,
'msg': message,
},
},
qos: qos,
);
}
Future<void> disconnect() async {
await _updatesSub?.cancel();
_updatesSub = null;
_client?.disconnect();
_client = null;
_emitState(LakiMqttConnectionState.disconnected);
}
Future<void> dispose() async {
await disconnect();
await _stateController.close();
await _messageController.close();
await _errorController.close();
}
void _listenUpdates(MqttServerClient client) {
_updatesSub?.cancel();
_updatesSub = client.updates?.listen((messages) {
for (final item in messages) {
final payload = item.payload;
if (payload is! MqttPublishMessage) {
continue;
}
final payloadText = MqttPublishPayload.bytesToStringAsString(
payload.payload.message,
);
_messageController.add(
LakiMqttMessage(
topic: item.topic,
payload: payloadText,
json: _tryDecodeJson(payloadText),
),
);
}
});
}
MqttServerClient _requireConnected() {
final client = _client;
if (client == null || !isConnected) {
throw StateError('MQTT 未连接');
}
return client;
}
void _emitState(LakiMqttConnectionState state) {
if (!_stateController.isClosed) {
_stateController.add(state);
}
}
void _emitError(String message) {
if (!_errorController.isClosed) {
_errorController.add(message);
}
}
Map<String, dynamic>? _tryDecodeJson(String payload) {
try {
final decoded = jsonDecode(payload);
return decoded is Map<String, dynamic> ? decoded : null;
} catch (_) {
return null;
}
}
}
......@@ -9,7 +9,11 @@ class StorageService {
static const String _keyRememberSmsCode = 'remember_sms_code';
static const String _keyRememberEnabled = 'remember_enabled';
static const String _keyUserInfo = 'user_info';
static const String _keyUserMac = 'user_mac';
static const String _keyClientId = 'client_id';
static const String _keyBoundBluetoothDeviceId = 'bound_bluetooth_device_id';
static const String _keyBoundBluetoothDeviceName =
'bound_bluetooth_device_name';
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(
......@@ -76,6 +80,7 @@ class StorageService {
Future<UserModel?> getUserInfo() async {
final value = await _storage.read(key: _keyUserInfo);
UserModel.MAC = await getUserMac();
if (value == null || value.isEmpty) return null;
final parts = value.split('|');
if (parts.length < 9) return null;
......@@ -96,6 +101,22 @@ class StorageService {
await _storage.delete(key: _keyUserInfo);
}
Future<void> saveUserMac(String mac) async {
UserModel.MAC = mac;
await _storage.write(key: _keyUserMac, value: mac);
}
Future<String?> getUserMac() async {
final mac = await _storage.read(key: _keyUserMac);
UserModel.MAC = mac;
return mac;
}
Future<void> deleteUserMac() async {
UserModel.MAC = null;
await _storage.delete(key: _keyUserMac);
}
// ==================== ClientId 相关 ====================
Future<void> saveClientId(String clientId) async {
......@@ -110,6 +131,30 @@ class StorageService {
await _storage.delete(key: _keyClientId);
}
// ==================== 蓝牙绑定相关 ====================
Future<void> saveBoundBluetoothDevice({
required String deviceId,
required String deviceName,
}) async {
await _storage.write(key: _keyBoundBluetoothDeviceId, value: deviceId);
await _storage.write(key: _keyBoundBluetoothDeviceName, value: deviceName);
}
Future<Map<String, String?>> getBoundBluetoothDevice() async {
final deviceId = await _storage.read(key: _keyBoundBluetoothDeviceId);
final deviceName = await _storage.read(key: _keyBoundBluetoothDeviceName);
return {
'deviceId': deviceId,
'deviceName': deviceName,
};
}
Future<void> deleteBoundBluetoothDevice() async {
await _storage.delete(key: _keyBoundBluetoothDeviceId);
await _storage.delete(key: _keyBoundBluetoothDeviceName);
}
// ==================== 记住密码相关 ====================
Future<void> saveRememberCredentials({
......
......@@ -6,21 +6,25 @@ import 'package:laki_icu_app/enums/video_stream_mode_enum.dart';
import 'package:laki_icu_app/services/monitoring_service.dart';
import 'package:laki_icu_app/services/p2p_video_service.dart';
import 'package:laki_icu_app/services/webrtc_service.dart';
import 'package:laki_icu_app/utils/bluetooth/index.dart';
import 'package:laki_icu_app/utils/storage/storage_service.dart';
import 'home_index_state.dart';
/// 设备凭据 —— Mock 阶段使用占位值,后续接口接入后替换
/// TODO: 从舱详情接口获取真实的 cameraSn 和 wifiPwd
const _mockDeviceNo = 'MOCK_DEVICE_SN';
const _mockDevicePassword = 'MOCK_WIFI_PWD';
class HomeIndexCubit extends Cubit<HomeIndexState> {
final MonitoringService _monitoringService;
final WebrtcService _webrtcService;
final P2pVideoService _p2pVideoService;
final StorageService _storageService;
final BleBluetoothManager _bluetoothManager;
final McuReportFrameDecoder _mcuReportDecoder;
StreamSubscription<WebrtcConnectionState>? _webrtcStateSub;
StreamSubscription<P2pServiceState>? _p2pStateSub;
StreamSubscription<List<BluetoothScanDevice>>? _bluetoothScanSub;
StreamSubscription<BluetoothConnectionStatus>? _bluetoothStateSub;
StreamSubscription<String>? _bluetoothMessageSub;
StreamSubscription<BluetoothDataPacket>? _bluetoothDataSub;
/// 连接代际计数器 —— 每次切换模式时 +1,
/// 防止旧连接的异步结果污染当前模式的状态。
......@@ -30,9 +34,14 @@ class HomeIndexCubit extends Cubit<HomeIndexState> {
: _monitoringService = MonitoringService(),
_webrtcService = WebrtcService(),
_p2pVideoService = P2pVideoService(),
_storageService = StorageService(),
_bluetoothManager = BleBluetoothManager(),
_mcuReportDecoder = McuReportFrameDecoder(),
super(const HomeIndexState()) {
_listenWebrtcState();
_listenP2pState();
_listenBluetoothState();
_loadBoundBluetoothDevice();
loadData();
}
......@@ -49,11 +58,69 @@ class HomeIndexCubit extends Cubit<HomeIndexState> {
_p2pStateSub = _p2pVideoService.stateStream.listen((state) {
if (isClosed) return;
emit(this.state.copyWith(
isP2pConnected: state == P2pServiceState.connected,
));
isP2pConnected: state == P2pServiceState.connected,
));
});
}
/// 监听蓝牙扫描、连接和数据状态
void _listenBluetoothState() {
_bluetoothScanSub = _bluetoothManager.scanDevicesStream.listen((devices) {
if (isClosed) return;
emit(state.copyWith(bluetoothDevices: devices));
});
_bluetoothStateSub = _bluetoothManager.stateStream.listen((status) {
if (isClosed) return;
emit(state.copyWith(bluetoothConnectionStatus: status));
});
_bluetoothMessageSub = _bluetoothManager.messageStream.listen((message) {
if (isClosed) return;
emit(state.copyWith(bluetoothMessage: message));
});
_bluetoothDataSub = _bluetoothManager.dataStream.listen((packet) {
if (isClosed) return;
try {
final reports = _mcuReportDecoder.addBytes(packet.bytes);
final latestReport = reports.isEmpty ? null : reports.last;
emit(state.copyWith(
latestBluetoothRawHex: packet.rawHex,
latestMcuReport: latestReport,
bluetoothMessage: latestReport == null
? state.bluetoothMessage
: '蓝牙数据解析成功: ${latestReport.sn.isEmpty ? latestReport.head : latestReport.sn}',
));
} catch (e) {
emit(state.copyWith(
latestBluetoothRawHex: packet.rawHex,
bluetoothMessage: '蓝牙数据解析失败: $e',
));
}
});
}
Future<void> _loadBoundBluetoothDevice() async {
final boundDevice = await _storageService.getBoundBluetoothDevice();
if (isClosed) return;
final deviceId = boundDevice['deviceId'];
if (deviceId != null && deviceId.isNotEmpty) {
final userMac = await _storageService.getUserMac();
if (userMac == null || userMac.isEmpty) {
await _storageService.saveUserMac(deviceId);
}
} else {
await _storageService.getUserMac();
}
if (isClosed) return;
emit(state.copyWith(
boundBluetoothDeviceId:
deviceId == null || deviceId.isEmpty ? null : deviceId,
boundBluetoothDeviceName: boundDevice['deviceName'],
));
}
Future<void> loadData() async {
emit(state.copyWith(isLoading: true, error: null));
try {
......@@ -203,6 +270,81 @@ class HomeIndexCubit extends Cubit<HomeIndexState> {
}
}
// ==================== 蓝牙扫描/绑定 ====================
Future<void> scanBluetoothDevices() async {
if (state.isBluetoothScanning) return;
emit(state.copyWith(
isBluetoothScanning: true,
bluetoothDevices: const [],
bluetoothMessage: '开始扫描蓝牙设备',
));
try {
await _bluetoothManager.scanForDevices(
namePrefixes: const [],
timeout: const Duration(seconds: 10),
);
} catch (e) {
if (isClosed) return;
emit(state.copyWith(bluetoothMessage: '蓝牙扫描失败: $e'));
} finally {
if (!isClosed) {
emit(state.copyWith(isBluetoothScanning: false));
}
}
}
Future<void> bindBluetoothDevice(BluetoothScanDevice device) async {
if (state.isBluetoothBinding) return;
_mcuReportDecoder.clear();
emit(state.copyWith(
isBluetoothBinding: true,
bindingBluetoothDeviceId: device.remoteId,
bluetoothMessage: '正在绑定蓝牙设备: ${device.name}',
));
try {
await _bluetoothManager.connectToDevice(device);
await _storageService.saveBoundBluetoothDevice(
deviceId: device.remoteId,
deviceName: device.name,
);
await _storageService.saveUserMac(device.remoteId);
if (isClosed) return;
emit(state.copyWith(
isBluetoothBinding: false,
bindingBluetoothDeviceId: '',
boundBluetoothDeviceId: device.remoteId,
boundBluetoothDeviceName: device.name,
bluetoothMessage: '蓝牙设备已绑定: ${device.name}',
));
} catch (e) {
if (isClosed) return;
emit(state.copyWith(
isBluetoothBinding: false,
bindingBluetoothDeviceId: '',
bluetoothMessage: '蓝牙绑定失败: $e',
));
}
}
Future<void> unbindBluetoothDevice() async {
await _bluetoothManager.disconnect();
await _storageService.deleteBoundBluetoothDevice();
await _storageService.deleteUserMac();
_mcuReportDecoder.clear();
if (isClosed) return;
emit(state.copyWith(
clearBoundBluetoothDevice: true,
clearLatestMcuReport: true,
bluetoothMessage: '蓝牙设备已解绑',
latestBluetoothRawHex: '',
));
}
/// 获取 WebRTC 渲染器供 UI 层使用
WebrtcService get webrtcService => _webrtcService;
......@@ -213,8 +355,12 @@ class HomeIndexCubit extends Cubit<HomeIndexState> {
Future<void> close() {
_webrtcStateSub?.cancel();
_p2pStateSub?.cancel();
_bluetoothScanSub?.cancel();
_bluetoothStateSub?.cancel();
_bluetoothMessageSub?.cancel();
_bluetoothDataSub?.cancel();
_webrtcService.dispose();
_p2pVideoService.dispose();
return super.close();
return _bluetoothManager.release().then((_) => super.close());
}
}
......@@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
import 'package:laki_icu_app/enums/video_stream_mode_enum.dart';
import 'package:laki_icu_app/models/bo/monitoring_bo.dart';
import 'package:laki_icu_app/services/webrtc_service.dart';
import 'package:laki_icu_app/utils/bluetooth/index.dart';
enum HomeIndexStatus {
initial,
......@@ -31,6 +32,36 @@ class HomeIndexState extends Equatable {
/// P2P 模式是否已连接
final bool isP2pConnected;
/// 蓝牙扫描中
final bool isBluetoothScanning;
/// 蓝牙绑定/连接处理中
final bool isBluetoothBinding;
/// 正在绑定/连接的蓝牙设备 ID
final String bindingBluetoothDeviceId;
/// 扫描到的蓝牙设备
final List<BluetoothScanDevice> bluetoothDevices;
/// 已绑定蓝牙设备 ID
final String? boundBluetoothDeviceId;
/// 已绑定蓝牙设备名称
final String? boundBluetoothDeviceName;
/// 蓝牙连接状态
final BluetoothConnectionStatus bluetoothConnectionStatus;
/// 蓝牙状态提示
final String? bluetoothMessage;
/// 最近收到的蓝牙原始 Hex 数据
final String? latestBluetoothRawHex;
/// 最近解析出的 MCU 上报数据
final McuReport? latestMcuReport;
const HomeIndexState({
this.status = HomeIndexStatus.initial,
this.isLoading = false,
......@@ -43,6 +74,16 @@ class HomeIndexState extends Equatable {
this.videoStreamMode = VideoStreamMode.webrtc,
this.isSwitchingMode = false,
this.isP2pConnected = false,
this.isBluetoothScanning = false,
this.isBluetoothBinding = false,
this.bindingBluetoothDeviceId = '',
this.bluetoothDevices = const [],
this.boundBluetoothDeviceId,
this.boundBluetoothDeviceName,
this.bluetoothConnectionStatus = BluetoothConnectionStatus.disconnected,
this.bluetoothMessage,
this.latestBluetoothRawHex,
this.latestMcuReport,
});
HomeIndexState copyWith({
......@@ -57,6 +98,18 @@ class HomeIndexState extends Equatable {
VideoStreamMode? videoStreamMode,
bool? isSwitchingMode,
bool? isP2pConnected,
bool? isBluetoothScanning,
bool? isBluetoothBinding,
String? bindingBluetoothDeviceId,
List<BluetoothScanDevice>? bluetoothDevices,
String? boundBluetoothDeviceId,
String? boundBluetoothDeviceName,
BluetoothConnectionStatus? bluetoothConnectionStatus,
String? bluetoothMessage,
String? latestBluetoothRawHex,
McuReport? latestMcuReport,
bool clearBoundBluetoothDevice = false,
bool clearLatestMcuReport = false,
}) {
return HomeIndexState(
status: status ?? this.status,
......@@ -66,11 +119,28 @@ class HomeIndexState extends Equatable {
patientInfo: patientInfo ?? this.patientInfo,
alerts: alerts ?? this.alerts,
menuItems: menuItems ?? this.menuItems,
videoConnectionState:
videoConnectionState ?? this.videoConnectionState,
videoConnectionState: videoConnectionState ?? this.videoConnectionState,
videoStreamMode: videoStreamMode ?? this.videoStreamMode,
isSwitchingMode: isSwitchingMode ?? this.isSwitchingMode,
isP2pConnected: isP2pConnected ?? this.isP2pConnected,
isBluetoothScanning: isBluetoothScanning ?? this.isBluetoothScanning,
isBluetoothBinding: isBluetoothBinding ?? this.isBluetoothBinding,
bindingBluetoothDeviceId:
bindingBluetoothDeviceId ?? this.bindingBluetoothDeviceId,
bluetoothDevices: bluetoothDevices ?? this.bluetoothDevices,
boundBluetoothDeviceId: clearBoundBluetoothDevice
? null
: boundBluetoothDeviceId ?? this.boundBluetoothDeviceId,
boundBluetoothDeviceName: clearBoundBluetoothDevice
? null
: boundBluetoothDeviceName ?? this.boundBluetoothDeviceName,
bluetoothConnectionStatus:
bluetoothConnectionStatus ?? this.bluetoothConnectionStatus,
bluetoothMessage: bluetoothMessage ?? this.bluetoothMessage,
latestBluetoothRawHex:
latestBluetoothRawHex ?? this.latestBluetoothRawHex,
latestMcuReport:
clearLatestMcuReport ? null : latestMcuReport ?? this.latestMcuReport,
);
}
......@@ -87,5 +157,15 @@ class HomeIndexState extends Equatable {
videoStreamMode,
isSwitchingMode,
isP2pConnected,
isBluetoothScanning,
isBluetoothBinding,
bindingBluetoothDeviceId,
bluetoothDevices,
boundBluetoothDeviceId,
boundBluetoothDeviceName,
bluetoothConnectionStatus,
bluetoothMessage,
latestBluetoothRawHex,
latestMcuReport,
];
}
......@@ -8,6 +8,7 @@ import 'package:laki_icu_app/models/bo/monitoring_bo.dart';
import 'cubit/home_index_cubit.dart';
import 'cubit/home_index_state.dart';
import 'widgets/alert_info_card.dart';
import 'widgets/bluetooth_bind_dialog.dart';
import 'widgets/metric_card.dart';
import 'widgets/patient_info_card.dart';
import 'widgets/sidebar_menu_item.dart';
......@@ -49,7 +50,12 @@ class _HomeIndexContentState extends State<HomeIndexContent> {
body: SafeArea(
child: Column(
children: [
_buildAppBar(),
BlocBuilder<HomeIndexCubit, HomeIndexState>(
buildWhen: (previous, current) =>
previous.boundBluetoothDeviceId !=
current.boundBluetoothDeviceId,
builder: (context, state) => _buildAppBar(state),
),
Expanded(
child: BlocBuilder<HomeIndexCubit, HomeIndexState>(
builder: (context, state) {
......@@ -92,7 +98,9 @@ class _HomeIndexContentState extends State<HomeIndexContent> {
);
}
Widget _buildAppBar() {
Widget _buildAppBar(HomeIndexState state) {
final isBluetoothBound = state.boundBluetoothDeviceId?.isNotEmpty == true;
return Container(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h),
decoration: const BoxDecoration(
......@@ -106,7 +114,8 @@ class _HomeIndexContentState extends State<HomeIndexContent> {
const CircleAvatar(
backgroundColor: Colors.white,
radius: 24,
child: Icon(Icons.local_hospital, color: Color(0xFF1E10B6), size: 28),
child:
Icon(Icons.local_hospital, color: Color(0xFF1E10B6), size: 28),
),
SizedBox(width: 16.w),
Text(
......@@ -118,7 +127,12 @@ class _HomeIndexContentState extends State<HomeIndexContent> {
),
),
const Spacer(),
_buildAppBarIcon(Icons.bluetooth, 'TSAAS'),
_buildAppBarIcon(
Icons.bluetooth,
'TSAAS',
isActive: isBluetoothBound,
onTap: _showBluetoothDialog,
),
SizedBox(width: 16.w),
_buildAppBarIcon(Icons.description, null),
SizedBox(width: 16.w),
......@@ -128,27 +142,52 @@ class _HomeIndexContentState extends State<HomeIndexContent> {
);
}
Widget _buildAppBarIcon(IconData icon, String? label) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
decoration: BoxDecoration(
border: Border.all(color: Colors.white24, width: 1),
borderRadius: BorderRadius.circular(20.r),
),
child: Row(
children: [
Icon(icon, color: Colors.white, size: 20.w),
if (label != null) ...[
SizedBox(width: 6.w),
Text(
label,
style: TextStyle(
fontSize: 18.sp,
color: Colors.white,
Widget _buildAppBarIcon(
IconData icon,
String? label, {
bool isActive = false,
VoidCallback? onTap,
}) {
final foregroundColor = isActive ? const Color(0xFF003A8C) : Colors.white;
final backgroundColor =
isActive ? const Color(0xFF00F6FF) : Colors.transparent;
final borderColor = isActive ? const Color(0xFFB8FFFF) : Colors.white24;
return GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h),
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(color: borderColor, width: 1),
borderRadius: BorderRadius.circular(20.r),
),
child: Row(
children: [
Icon(icon, color: foregroundColor, size: 20.w),
if (label != null) ...[
SizedBox(width: 6.w),
Text(
label,
style: TextStyle(
fontSize: 18.sp,
color: foregroundColor,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w400,
),
),
),
],
],
],
),
),
);
}
void _showBluetoothDialog() {
showDialog<void>(
context: context,
builder: (_) => BlocProvider.value(
value: _homeIndexCubit,
child: const BluetoothBindDialog(),
),
);
}
......
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:laki_icu_app/utils/bluetooth/index.dart';
import 'package:laki_icu_app/views/home/index/cubit/home_index_cubit.dart';
import 'package:laki_icu_app/views/home/index/cubit/home_index_state.dart';
class BluetoothBindDialog extends StatefulWidget {
const BluetoothBindDialog({super.key});
@override
State<BluetoothBindDialog> createState() => _BluetoothBindDialogState();
}
class _BluetoothBindDialogState extends State<BluetoothBindDialog> {
bool _hasAutoStartedScan = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _hasAutoStartedScan) return;
_hasAutoStartedScan = true;
final cubit = context.read<HomeIndexCubit>();
if (!cubit.state.isBluetoothScanning) {
cubit.scanBluetoothDevices();
}
});
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: const Color(0xFFF4F4F4),
insetPadding: EdgeInsets.zero,
child: BlocBuilder<HomeIndexCubit, HomeIndexState>(
builder: (context, state) {
return SizedBox.expand(
child: _buildContent(context, state),
);
},
),
);
}
Widget _buildContent(BuildContext context, HomeIndexState state) {
return Container(
width: double.infinity,
// margin: EdgeInsets.fromLTRB(16.w, 0, 16.w, 16.h),
padding: EdgeInsets.fromLTRB(108.w, 52.h, 108.w, 94.h),
decoration: BoxDecoration(
color: const Color(0xFF79A9EE),
border: Border.all(color: const Color(0xFF0799FF), width: 3.w),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTopBar(context),
SizedBox(height: 38.h),
Expanded(child: _buildDevicePanel(context, state)),
],
),
);
}
Widget _buildTopBar(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'设备连接',
style: TextStyle(
color: Colors.white,
fontSize: 42.sp,
fontWeight: FontWeight.w400,
),
),
SizedBox(height: 14.h),
Text(
'搜索蓝牙设备完成配对连接',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.84),
fontSize: 22.sp,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: Colors.white, size: 48.w),
tooltip: '关闭',
padding: EdgeInsets.zero,
constraints: BoxConstraints.tight(Size(64.w, 64.w)),
),
],
);
}
Widget _buildDevicePanel(BuildContext context, HomeIndexState state) {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(72.w, 56.h, 72.w, 34.h),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF0028E8), Color(0xFF001646)],
),
borderRadius: BorderRadius.circular(14.r),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.26),
blurRadius: 12.r,
offset: Offset(0, 6.h),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildPanelStatus(context, state),
SizedBox(height: 28.h),
Expanded(child: _buildDeviceList(context, state)),
_buildLatestData(state),
],
),
);
}
Widget _buildPanelStatus(BuildContext context, HomeIndexState state) {
final message = state.isBluetoothScanning
? '自动发现附近可配对的监护舱设备,搜索中...'
: state.bluetoothDevices.isEmpty
? '未发现设备,可重新搜索附近蓝牙设备'
: '自动发现附近可配对的监护舱设备';
return Row(
children: [
Expanded(
child: Text(
message,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.88),
fontSize: 21.sp,
fontWeight: FontWeight.w400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
SizedBox(width: 16.w),
TextButton.icon(
onPressed: state.isBluetoothScanning
? null
: () => context.read<HomeIndexCubit>().scanBluetoothDevices(),
icon: state.isBluetoothScanning
? SizedBox(
width: 18.w,
height: 18.w,
child: CircularProgressIndicator(
strokeWidth: 2.w,
color: Colors.white,
),
)
: Icon(Icons.refresh, size: 22.w),
label: Text(state.isBluetoothScanning ? '搜索中' : '重新搜索'),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white70,
textStyle: TextStyle(fontSize: 17.sp),
),
),
],
);
}
Widget _buildDeviceList(BuildContext context, HomeIndexState state) {
if (state.bluetoothDevices.isEmpty) {
return Center(
child: Text(
state.isBluetoothScanning ? '正在扫描附近设备...' : '暂无设备',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.76),
fontSize: 24.sp,
),
),
);
}
final devices = _sortDevicesForDisplay(state);
return ListView.separated(
padding: EdgeInsets.zero,
itemCount: devices.length,
separatorBuilder: (_, __) => SizedBox(height: 18.h),
itemBuilder: (context, index) {
final device = devices[index];
final isBound = state.boundBluetoothDeviceId == device.remoteId;
final isBinding = state.bindingBluetoothDeviceId == device.remoteId;
return _BluetoothDeviceRow(
key: ValueKey(device.remoteId),
device: device,
isBound: isBound,
isBinding: isBinding,
isDisabled: state.isBluetoothBinding,
onTap: isBound
? () => _confirmUnbindBluetoothDevice(context, device)
: () => context.read<HomeIndexCubit>().bindBluetoothDevice(
device,
),
);
},
);
}
List<BluetoothScanDevice> _sortDevicesForDisplay(HomeIndexState state) {
final boundDeviceId = state.boundBluetoothDeviceId;
if (boundDeviceId == null || boundDeviceId.isEmpty) {
return state.bluetoothDevices;
}
return List<BluetoothScanDevice>.from(state.bluetoothDevices)
..sort((left, right) {
final leftIsBound = left.remoteId == boundDeviceId;
final rightIsBound = right.remoteId == boundDeviceId;
if (leftIsBound == rightIsBound) {
return 0;
}
return leftIsBound ? -1 : 1;
});
}
Future<void> _confirmUnbindBluetoothDevice(
BuildContext context,
BluetoothScanDevice device,
) async {
final shouldUnbind = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: const Text('解绑蓝牙设备'),
content: Text('是否要解绑 ${device.name}?解绑后可重新选择设备绑定。'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('确认解绑'),
),
],
);
},
);
if (shouldUnbind == true && context.mounted) {
await context.read<HomeIndexCubit>().unbindBluetoothDevice();
}
}
Widget _buildLatestData(HomeIndexState state) {
final report = state.latestMcuReport;
final rawHex = state.latestBluetoothRawHex;
final message = state.bluetoothMessage;
if (report == null &&
(rawHex == null || rawHex.isEmpty) &&
(message == null || message.isEmpty)) {
return SizedBox(height: 10.h);
}
if (report != null) {
return _BluetoothReportSummary(report: report);
}
return Padding(
padding: EdgeInsets.only(top: 18.h),
child: Text(
rawHex != null && rawHex.isNotEmpty ? '最新数据: $rawHex' : message!,
style: TextStyle(
color: rawHex != null && rawHex.isNotEmpty
? const Color(0xFF00F6FF)
: Colors.white.withValues(alpha: 0.66),
fontSize: 15.sp,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
}
}
class _BluetoothReportSummary extends StatelessWidget {
const _BluetoothReportSummary({required this.report});
final McuReport report;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: EdgeInsets.only(top: 18.h),
padding: EdgeInsets.symmetric(horizontal: 18.w, vertical: 14.h),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
report.sn.isEmpty ? 'SN --' : 'SN ${report.sn}',
style: TextStyle(
color: const Color(0xFF00F6FF),
fontSize: 15.sp,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_BluetoothReportMetric(
label: '温度',
value: report.mainTemperature.toStringAsFixed(1),
unit: '℃',
),
_BluetoothReportMetric(
label: '湿度',
value: '${report.humidity}',
unit: '%',
),
_BluetoothReportMetric(
label: '氧气',
value: '${report.oxygenConcentration}',
unit: '%',
),
_BluetoothReportMetric(
label: 'CO2',
value: '${report.co2Measurement}',
unit: 'ppm',
),
],
),
);
}
}
class _BluetoothReportMetric extends StatelessWidget {
const _BluetoothReportMetric({
required this.label,
required this.value,
required this.unit,
});
final String label;
final String value;
final String unit;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 128.w,
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
TextSpan(
text: '$label ',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.58),
fontSize: 13.sp,
),
),
TextSpan(
text: value,
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
),
TextSpan(
text: unit,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.72),
fontSize: 12.sp,
),
),
],
),
),
);
}
}
class _BluetoothDeviceRow extends StatelessWidget {
const _BluetoothDeviceRow({
super.key,
required this.device,
required this.isBound,
required this.isBinding,
required this.isDisabled,
required this.onTap,
});
final BluetoothScanDevice device;
final bool isBound;
final bool isBinding;
final bool isDisabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
const highlightColor = Color(0xFF00F6FF);
final textColor = isBound ? highlightColor : Colors.white;
final statusText = isBound
? '已连接'
: isBinding
? '连接中'
: '未连接';
return InkWell(
onTap: isDisabled ? null : onTap,
borderRadius: BorderRadius.circular(8.r),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 0, vertical: 14.h),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.name,
style: TextStyle(
color: textColor,
fontSize: 26.sp,
fontWeight: isBound ? FontWeight.w600 : FontWeight.w400,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 5.h),
Text(
'${device.remoteId} RSSI ${device.rssi}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.42),
fontSize: 13.sp,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
SizedBox(width: 32.w),
SizedBox(
width: 138.w,
child: Text(
statusText,
textAlign: TextAlign.left,
style: TextStyle(
color: textColor,
fontSize: 25.sp,
fontWeight: isBound ? FontWeight.w600 : FontWeight.w400,
),
),
),
],
),
),
);
}
}
......@@ -9,6 +9,7 @@ import device_info_plus
import flutter_image_compress_macos
import flutter_secure_storage_macos
import flutter_webrtc
import reactive_ble_mobile
import share_plus
import shared_preferences_foundation
import url_launcher_macos
......@@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
ReactiveBlePlugin.register(with: registry.registrar(forPlugin: "ReactiveBlePlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
......
......@@ -50,16 +50,15 @@ dependencies:
logger: ^2.7.0
fluttertoast: ^9.0.0
permission_handler: ^11.3.1
flutter_usbcamera: ^0.0.1
flutter_usbcamera:
path: ./third_party/flutter_usbcamera
path_provider: ^2.1.2
flutter_webrtc:
path: ./plugins/flutter_webrtc
vsdk:
path: ./plugins/vsdk
# flutter_usbcamera: ^0.0.1
# fl_chart: ^0.71.0
# fl_chart: ^1.1.0
flutter_reactive_ble: ^5.5.0
mqtt_client: ^10.5.1
dev_dependencies:
flutter_test:
sdk: flutter
......@@ -84,6 +83,7 @@ dev_dependencies:
# The following section is specific to Flutter packages.
flutter:
default-flavor: dev
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment