Commit 3e1af08e authored by 张宏's avatar 张宏

对接完成 B端绑定宠物,mqtt下发到设备

parent fbfad9ea
......@@ -15,12 +15,13 @@ class CabinRepository {
Future<ResponseModel<CabinDetailResponse>> getCabinDetail(
String carbinSn,
) {
if (Constants.useMockData) {
return _mockCabinDetail();
}
// if (Constants.useMockData) {
// return _mockCabinDetail();
// }
return DioRequest.instance.get<CabinDetailResponse>(
'/icu/detail',
queryParameters: {'carbinSn': carbinSn},
// queryParameters: {'carbinSn': carbinSn},
queryParameters: {'carbinSn': 'CNA07212L'},
fromJsonT: (data) =>
CabinDetailResponse.fromJson(data as Map<String, dynamic>),
);
......
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:laki_icu_app/utils/bluetooth/index.dart';
import 'package:mqtt_client/mqtt_client.dart';
......@@ -69,11 +70,16 @@ class LakiMqttTopics {
static String icuUp(String deviceSn) => 'icu/$deviceSn/up';
static String cameraUp(String deviceSn) => 'camera/$deviceSn/up'; // stephen 上行 宠物异常行为处理结果 camera/{SN}/up → CameraResultHandler
static String icuAlarm(String deviceSn) => 'icu/$deviceSn/alarm'; // stephen 上行 传感器(设备)异常告警 icu/{SN}/alarm → DeviceAlarmHandler
static String icuTo(String deviceSn) => 'icu/$deviceSn/to';
static String icuPetBind(String deviceSn) => 'icu/$deviceSn/pet/bind';
static String icuPetBind(String deviceSn) => 'icu/$deviceSn/pet/bind'; // stephen 下行 宠物 绑定、出舱
static String cameraTo(String cabinSn) => 'camera/$cabinSn/to'; // 获取摄像头通道的告警信息
static String cameraTo(String cabinSn) => 'camera/$cabinSn/to'; // stephen 下行 获取摄像头通道的告警信息
static const String clientConnected = r'$events/client_connected';
......@@ -175,6 +181,11 @@ class LakiMqttClient {
_client = client;
_listenUpdates(client);
_emitState(LakiMqttConnectionState.connected);
// ignore: avoid_print
print(
'[LakiMqttClient] ✅ MQTT 已连接成功, clientId=${_config.clientId} '
'host=${_config.host}',
);
}
void subscribe(String topic, {MqttQos qos = MqttQos.atLeastOnce}) {
......@@ -263,6 +274,37 @@ class LakiMqttClient {
);
}
/// 上行 出舱数据
/// create by stephen
/// [deviceSn] 设备 SN,[type] 操作类型(unbindPet),
/// [petData] 宠物信息,为 null 时发送空对象。
// TODO: 服务端尚未确定具体数据结构,等确定后再调整
int publishPetUnBind({
required String deviceSn,
required String type,
required Map<String, dynamic>? petData,
MqttQos qos = MqttQos.atLeastOnce,
}) {
return publishJson(
LakiMqttTopics.icuPetBind(deviceSn),
{
'eventId': '${DateTime.now().millisecondsSinceEpoch}_${_randomHex(4)}',
'deviceId': deviceSn,
'deviceType': 'ICU',
'timestamp': DateTime.now().millisecondsSinceEpoch,
'type': type,
'data': petData ?? {},
},
qos: qos,
);
}
/// 生成指定长度的随机 hex 字符串
String _randomHex(int length) {
final random = Random();
return List.generate(length, (_) => random.nextInt(16).toRadixString(16)).join();
}
Future<void> disconnect() async {
await _updatesSub?.cancel();
_updatesSub = null;
......
......@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:laki_icu_app/enums/video_stream_mode_enum.dart';
import 'package:laki_icu_app/models/bo/cabin_detail_response.dart';
import 'package:laki_icu_app/models/bo/monitoring_bo.dart';
import 'package:laki_icu_app/models/bo/camera_alarm_bo.dart';
import 'package:laki_icu_app/services/cabin_service.dart';
......@@ -75,6 +75,10 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
_listenBluetoothReadInfo();
_listenMcuReportChanged();
_loadBoundBluetoothDevice();
// todo 测试使用 待删除
_fetchCabinDetailAndConnectVideo("CNA07212L");
_connectMqttForDeviceSn("CNA07212L");
}
/// 监听 WebRTC 连接状态变化并同步到 State
......@@ -150,7 +154,7 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
).copyWith(sn: reportSn.isEmpty ? null : reportSn);
if (bluetoothReadInfo.hasSn) {
await _storageService.saveBluetoothReadSn(bluetoothReadInfo.sn!);
await _connectMqttForDeviceSn(bluetoothReadInfo.sn!);
await _connectMqttForDeviceSn(bluetoothReadInfo.sn!); // stephen 需要保留,选择蓝牙之后获取到sn就需要连接上mqtt
await _fetchCabinDetailAndConnectVideo(bluetoothReadInfo.sn!);
}
eventBus.emit(
......@@ -266,8 +270,9 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
/// 页面初始化入口。
///
/// 数据不再通过 HTTP 获取:
/// - 宠物信息由蓝牙 SN → /icu/detail 接口([_fetchCabinDetailAndConnectVideo])获取
/// - 告警信息由 MQTT camera/{sn}/to 推送获取
/// - 宠物信息由 MQTT icu/{SN}/pet/bind 推送获取
/// - 摄像头凭证由蓝牙 SN → /icu/detail 接口([_fetchCabinDetailAndConnectVideo])获取
/// - 告警信息由 MQTT camera/{SN}/to 推送获取
/// - 指标数据由蓝牙 MCU 上报实时填充
Future<void> loadData() async {}
......@@ -343,12 +348,10 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
final detail = await _cabinService.getCabinDetail(carbinSn);
if (isClosed) return;
// 仅从舱详情接口获取 cameraSn / wifiPwd,宠物信息改由 MQTT pet/bind 推送
emit(state.copyWith(
cabinCameraSn: detail.cameraSn,
cabinWifiPwd: detail.wifiPwd,
patientInfo: detail.petInfo != null
? _mapPetInfoToPatientInfo(detail.petInfo!)
: state.patientInfo,
));
// 凭证就绪后发起视频连接
......@@ -361,21 +364,6 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
}
}
/// 将 [PetInfoResponse] 映射为 [PatientInfoBO]
PatientInfoBO _mapPetInfoToPatientInfo(PetInfoResponse pet) {
return PatientInfoBO(
name: (pet.petName ?? pet.name) ?? '',
type: (pet.petType ?? pet.type) ?? '',
phone: pet.ownerPhone ?? '',
breed: pet.breedTag ?? '',
doctor: '缺失字段',
avatarUrl: pet.avatar ?? '',
disease: pet.disease ?? '',
checkInDate: '缺失字段',
careLevel: '缺失字段',
);
}
// ==================== 模式切换 ====================
/// 切换视频传输模式
......@@ -691,6 +679,11 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
_handleCameraAlarmMessage(json);
return;
}
if (json != null &&
message.topic == LakiMqttTopics.icuPetBind(_mqttDeviceSn ?? '')) {
_handlePetBindMessage(json);
return;
}
emit(state.copyWith(
mqttMessage: 'MQTT 收到 ${message.topic}: ${message.payload}',
));
......@@ -730,6 +723,46 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
));
}
/// 处理 MQTT pet/bind 宠物绑定消息
///
/// 服务端推送的宠物绑定数据格式:
/// ```json
/// {
/// "type": "bindPet",
/// "data": { "name": "旺财", "type": "dog", "breedTag": "金毛", ... }
/// }
/// ```
/// [type] 为 `bindPet` 时设置 petInfo,为 `unbindPet` 时清空 petInfo
void _handlePetBindMessage(Map<String, dynamic> json) {
final type = json['type'] as String?;
if (type == null) return;
if (type == 'unbindPet') {
// 出舱:清空宠物信息
emit(state.copyWith(clearPatientInfo: true));
return;
}
if (type != 'bindPet') return;
final data = json['data'];
if (data is! Map<String, dynamic>) return;
final patientInfo = PatientInfoBO(
name: (data['name'] as String?) ?? '',
type: (data['type'] as String?) ?? '',
phone: (data['ownerPhone'] as String?) ?? '',
breed: (data['breedTag'] as String?) ?? '',
doctor: '缺失字段',
avatarUrl: (data['avatar'] as String?) ?? '',
disease: (data['disease'] as String?) ?? '',
checkInDate: (data['createTime'] as String?) ?? '缺失字段',
careLevel: '缺失字段',
);
emit(state.copyWith(patientInfo: patientInfo));
}
/// 毫秒时间戳 → HH:mm:ss 格式(与 Android 一致)
String _formatTimestamp(int milliseconds) {
final dateTime =
......@@ -952,26 +985,60 @@ class MonitoringIndexCubit extends Cubit<MonitoringIndexState> {
}
/// 清空宠物信息(出舱)
///
/// 通过 MQTT 上行出舱数据后清空 state 中的宠物信息。
void clearPatientInfo() {
if (isClosed) return;
final deviceSn = state.mqttDeviceSn;
final client = _mqttClient;
debugPrint('[出舱] deviceSn=$deviceSn mqttClient=${client != null} isConnected=${client?.isConnected ?? false} patientInfo=${state.patientInfo?.name}');
if (deviceSn != null && deviceSn.isNotEmpty && client != null && client.isConnected) {
try {
final msgId = client.publishPetUnBind(
deviceSn: deviceSn,
type: 'unbindPet', // TODO: 出舱 type 后续等服务端确定后再调整
petData: _patientInfoToMap(),
);
debugPrint('[出舱] MQTT publishPetUnBind 成功 msgId=$msgId topic=icu/$deviceSn/pet/bind');
} catch (e) {
debugPrint('[出舱] MQTT publishPetUnBind 失败: $e');
if (!isClosed) {
emit(state.copyWith(mqttMessage: 'MQTT 出舱上报失败: $e'));
}
}
} else {
debugPrint('[出舱] 跳过 MQTT 上行: '
'deviceSn=${deviceSn ?? "null"} '
'hasClient=${client != null} '
'isConnected=${client?.isConnected ?? false}');
}
emit(state.copyWith(clearPatientInfo: true));
}
/// 将当前宠物信息转为 Map,供 MQTT 上行使用
Map<String, dynamic>? _patientInfoToMap() {
final info = state.patientInfo;
if (info == null) return null;
return {
'name': info.name,
'petType': info.type,
'phone': info.phone,
'breed': info.breed,
'doctor': info.doctor,
'disease': info.disease,
'checkInDate': info.checkInDate,
'careLevel': info.careLevel,
};
}
/// 设置测试宠物信息(绑定宠物)
void setTestPatientInfo() {
if (isClosed) return;
const testPatientInfo = PatientInfoBO(
name: '妮蔻',
type: '猫',
phone: '130-1111-3333',
breed: '长毛三花',
doctor: '张医生',
disease: '胃炎',
checkInDate: '2025-6-15',
careLevel: '特级',
avatarUrl: '',
);
emit(state.copyWith(patientInfo: testPatientInfo));
// todo 平板端暂时不做宠物绑定 stephen
}
/// 获取 WebRTC 渲染器供 UI 层使用
......
......@@ -181,39 +181,39 @@ class MonitoringPetInfoCard extends StatelessWidget {
),
SizedBox(height: 26.h),
// 绑定宠物按钮
GestureDetector(
onTap: onBindPet,
child: Container(
width: double.infinity,
height: 140.h,
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('lib/assets/monitoring/title_bg_spacewalk.png'),
fit: BoxFit.fill,
),
),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// GestureDetector(
// onTap: onBindPet,
// child: Container(
// width: double.infinity,
// height: 140.h,
// decoration: const BoxDecoration(
// image: DecorationImage(
// image: AssetImage('lib/assets/monitoring/title_bg_spacewalk.png'),
// fit: BoxFit.fill,
// ),
// ),
// child: Center(
// child: Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
SizedBox(width: 24.w),
Text(
'绑定宠物',
style: TextStyle(
color: Colors.white,
fontSize: 32.sp,
fontWeight: FontWeight.w700,
letterSpacing: 4.w,
),
),
SizedBox(width: 24.w),
// SizedBox(width: 24.w),
// Text(
// '绑定宠物',
// style: TextStyle(
// color: Colors.white,
// fontSize: 32.sp,
// fontWeight: FontWeight.w700,
// letterSpacing: 4.w,
// ),
// ),
// SizedBox(width: 24.w),
],
),
),
),
),
// ],
// ),
// ),
// ),
// ),
],
);
}
......@@ -349,10 +349,15 @@ class MonitoringPetInfoCard extends StatelessWidget {
);
}
String _truncate(String text, {int maxChars = 10}) {
if (text.length <= maxChars) return text;
return '${text.substring(0, maxChars)}...';
}
Widget _buildInfoBlock(PatientInfoBO info) {
return Column(
children: [
_buildInfoItem(Icons.description_outlined, '宠物病因', info.disease),
_buildInfoItem(Icons.description_outlined, '宠物病因', _truncate(info.disease)),
SizedBox(height: 20.h),
_buildInfoItem(Icons.home_outlined, '入住时间', info.checkInDate),
SizedBox(height: 20.h),
......
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