From db70665cedd484c2bbe3cc78d543d85494fd470f Mon Sep 17 00:00:00 2001
From: Songzex <2402265378@qq.com>
Date: Fri, 7 Aug 2026 08:49:29 +0800
Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E6=AC=A1=E5=A4=A7=E7=9A=84=E6=8F=90?=
=?UTF-8?q?=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
android/app/build.gradle.kts | 25 +-
android/app/src/main/AndroidManifest.xml | 8 +
assets/languages/zh-CN.json | 3 +-
dist/download/.gitkeep | 3 +
dist/patch/.gitkeep | 3 +
docs/DRONE_STATION_OSD_CARD_USAGE.md | 296 ++++
docs/UAV_VIDEO_COMPLETION_SUMMARY.md | 195 ++
docs/UAV_VIDEO_INTEGRATION_GUIDE.md | 323 ++++
docs/UAV_VIDEO_PAGE_INTEGRATION.md | 298 ++++
docs/UAV_VIDEO_USAGE_EXAMPLES.md | 642 +++++++
lib/components/device_status_modal.dart | 16 +-
lib/core/bluetooth/ble_manager.dart | 448 +++++
lib/core/bluetooth/ble_protocol_decoder.dart | 722 ++++++++
lib/core/bluetooth/bytes_util.dart | 38 +
lib/core/bluetooth/mc700_device_config.dart | 402 +++++
lib/core/bluetooth/protocol_parser.dart | 122 ++
lib/core/consts/http_api_consts.dart | 62 +-
lib/core/di/injection.dart | 120 +-
lib/core/domain/entities/user_entity.dart | 38 +-
.../managers/drone_task_state_manager.dart | 116 ++
lib/core/network/dio_client.dart | 36 +-
.../datasources/drone_osd_datasource.dart | 32 +-
.../datasources/task_message_datasource.dart | 31 +-
.../task_message_repository_impl.dart | 4 +-
.../mqtt/domain/models/mqtt_config.dart | 2 +-
.../repositories/task_message_repository.dart | 2 +-
lib/core/network/tcp/tcp_client.dart | 254 ++-
lib/core/router/app_router.dart | 8 +-
lib/core/router/route_paths.dart | 4 +
.../services/device_permission_service.dart | 69 +
lib/features/auth/data/models/user_model.dart | 23 +-
.../auth/presentation/bloc/auth_cubit.dart | 354 +++-
.../auth/presentation/bloc/login_cubit.dart | 11 +-
.../datasources/device_task_datasource.dart | 81 +-
.../presentation/bloc/device_status_bloc.dart | 109 +-
.../presentation/bloc/device_task_cubit.dart | 10 +-
.../presentation/bloc/devices_cubit.dart | 9 +-
.../bloc/permission_request_bloc.dart | 8 +
.../bloc/permission_request_event.dart | 5 +
.../presentation/pages/route_plan_page.dart | 2 +-
.../pages/running_status_page.dart | 21 +-
.../presentation/widgets/ImmersionHeader.dart | 1 -
.../widgets/map/testmap_pages.dart | 378 ++--
.../pages/machine_details_page.dart | 2 +-
.../my/presentation/bloc/my_cubit.dart | 7 +
.../bloc/remote_control_cubit.dart | 24 +
.../widgets/right_joystick_area.dart | 4 +-
.../widgets/webrtc/webrtc_local_player.dart | 8 +
.../datasources/bind_device_datasource.dart | 14 +
.../impl/bind_device_datasource_impl.dart | 159 ++
.../bind_device_repository_impl.dart | 80 +
.../domain/entities/bind_device_entities.dart | 62 +
.../domain/entities/drone_station_entity.dart | 18 +
.../repositories/bind_device_repository.dart | 19 +
.../domain/usecases/bind_device_usecases.dart | 50 +
.../usecases/pause_flight_task_usecase.dart | 15 +
.../domain/usecases/return_home_usecase.dart | 15 +
.../presentation/bloc/device_status_bloc.dart | 48 +-
.../bloc/device_status_event.dart | 8 +-
.../presentation/cubit/bind_device_cubit.dart | 256 +++
.../presentation/cubit/bind_device_state.dart | 68 +
.../presentation/pages/bind_device_page.dart | 429 +++++
.../pages/ble_device_detail_page.dart | 1573 +++++++++++++++++
.../presentation/pages/create_task_page.dart | 8 +-
.../pages/device_status_page.dart | 432 ++++-
.../pages/drone_mission_control_page.dart | 30 +-
.../pages/drone_station_detail_page.dart | 303 +++-
.../pages/drone_station_status_page.dart | 14 +-
.../pages/drone_video_control_page.dart | 644 +++++--
.../presentation/pages/qr_scanner_page.dart | 242 +++
.../widgets/bluetooth_scan_modal.dart | 766 ++++++++
.../presentation/widgets/drone_osd_card.dart | 881 +++++----
.../widgets/drone_station_osd_card.dart | 926 +++++-----
.../widgets/robot_header_card.dart | 32 +-
.../widgets/robot_status_bar.dart | 205 ++-
.../device_run_param_remote_datasource.dart | 9 +
...vice_run_param_remote_datasource_impl.dart | 65 +
.../data/models/device_run_param_model.dart | 49 +
.../device_run_param_repository_impl.dart | 27 +
.../entities/device_run_param_entity.dart | 27 +
.../device_run_param_repository.dart | 8 +
.../usecases/device_run_param_usecases.dart | 24 +
.../pages/robot_param_settings_page.dart | 581 ++++++
.../data/datasources/site_datasource.dart | 2 +-
.../datasources/site_datasource_impl.dart | 61 +-
.../repositories/site_repository_impl.dart | 4 +-
.../domain/repositories/site_repository.dart | 2 +-
.../usecases/get_site_list_usecase.dart | 6 +-
.../home/presentation/bloc/home_v2_bloc.dart | 83 +-
.../home/presentation/pages/home_v2_page.dart | 59 +-
.../report_remote_datasource_impl.dart | 174 +-
.../v2/report/data/models/report_model.dart | 18 +
lib/features/v2/report/di/report_di.dart | 22 +-
.../report/domain/entities/report_entity.dart | 12 +
.../constants/report_constants.dart | 30 +-
.../presentation/cubit/report_cubit.dart | 200 ++-
.../presentation/pages/report_page.dart | 446 +++--
.../presentation/states/report_state.dart | 43 +
.../presentation/widgets/device_selector.dart | 48 +-
.../presentation/widgets/level_selector.dart | 15 +-
.../presentation/widgets/media_uploader.dart | 214 +--
.../widgets/report_type_selector.dart | 36 +-
.../site/presentation/cubit/site_cubit.dart | 53 +-
.../widgets/site_selector_widget.dart | 265 +++
.../alarm_detail_remote_datasource.dart | 7 +-
.../alarm_dispatch_remote_datasource.dart | 8 +
.../datasources/alarm_remote_datasource.dart | 14 +-
.../alarm_detail_remote_datasource_impl.dart | 139 +-
...alarm_dispatch_remote_datasource_impl.dart | 171 ++
.../impl/alarm_remote_datasource_impl.dart | 116 +-
.../data/models/alarm_detail_model.dart | 165 +-
.../data/models/alarm_handle_model.dart | 25 +
.../data/models/alarm_model.dart | 89 +-
.../alarm_detail_repository_impl.dart | 20 +-
.../alarm_dispatch_repository_impl.dart | 19 +
.../repositories/alarm_repository_impl.dart | 45 +-
.../domain/entities/alarm_count_entity.dart | 16 +
.../domain/entities/alarm_detail_entity.dart | 126 ++
.../entities/alarm_dispatch_entity.dart | 89 +
.../repositories/alarm_detail_repository.dart | 9 +-
.../domain/repositories/alarm_repository.dart | 13 +-
.../usecases/alarm_actions_usecase.dart | 15 +-
.../usecases/dispatch_workorder_usecase.dart | 39 +
.../usecases/get_alarm_count_usecase.dart | 4 +-
.../usecases/get_alarm_list_usecase.dart | 14 +-
.../get_alarm_order_config_usecase.dart | 16 +
.../presentation/bloc/alarm_cubit.dart | 254 ++-
.../presentation/bloc/alarm_detail_cubit.dart | 81 +-
.../presentation/bloc/alarm_detail_state.dart | 14 +-
.../presentation/bloc/alarm_state.dart | 31 +-
.../constants/alarm_constants.dart | 15 +-
.../cubit/alarm_dispatch_cubit.dart | 230 +++
.../presentation/pages/alarm_center_page.dart | 116 +-
.../presentation/pages/alarm_detail_page.dart | 276 ++-
.../pages/alarm_dispatch_page.dart | 560 ++++++
.../presentation/pages/alarm_handle_page.dart | 639 +++++++
.../routes/alarm_center_routes.dart | 55 +-
.../states/alarm_dispatch_state.dart | 129 ++
.../widgets/alarm_count_card.dart | 49 +-
.../presentation/widgets/alarm_item_card.dart | 26 +-
.../presentation/widgets/alarm_tab_bar.dart | 96 +-
.../work_order_remote_datasource.dart | 19 +
.../work_order_remote_datasource_impl.dart | 179 ++
.../data/models/work_order_model.dart | 207 +++
.../work_order_repository_impl.dart | 57 +
.../v2/work_order/di/work_order_di.dart | 43 +
.../domain/entities/work_order_entity.dart | 167 ++
.../repositories/work_order_repository.dart | 19 +
.../domain/usecases/work_order_usecases.dart | 142 ++
.../presentation/cubit/work_order_cubit.dart | 150 ++
.../presentation/states/work_order_state.dart | 58 +
lib/features/v2/work_order/work_order.dart | 4 +
.../workorder_remote_datasource.dart | 31 +-
.../workorder_remote_datasource_impl.dart | 194 +-
.../data/models/workorder_model.dart | 555 +++++-
.../workorder_repository_impl.dart | 84 +-
.../domain/entities/workorder_entity.dart | 115 ++
.../repositories/workorder_repository.dart | 33 +-
.../get_workorder_detail_usecase.dart | 14 +
.../usecases/get_workorder_list_usecase.dart | 12 +-
.../usecases/transfer_workorder_usecase.dart | 46 +
.../presentation/cubit/transfer_cubit.dart | 133 ++
.../presentation/cubit/workorder_cubit.dart | 191 +-
.../cubit/workorder_detail_cubit.dart | 67 +
.../presentation/pages/transfer_dialog.dart | 335 ++++
.../pages/workorder_detail_page.dart | 1108 ++++++++++++
.../pages/workorder_on_site_page.dart | 992 +++++++++++
.../presentation/pages/workorder_page.dart | 176 +-
.../pages/workorder_process_page.dart | 353 ++++
.../pages/workorder_receipt_page.dart | 541 ++++++
.../pages/workorder_report_page.dart | 440 +++++
.../presentation/routes/workorder_routes.dart | 87 +
.../widgets/workorder_tabbar.dart | 148 +-
lib/features/v2/workorder/workorder.dart | 6 +-
lib/main.dart | 90 +-
macos/Flutter/GeneratedPluginRegistrant.swift | 2 +
pubspec.lock | 66 +-
pubspec.yaml | 7 +-
test_run.log | Bin 0 -> 14374 bytes
.../flutter/generated_plugin_registrant.cc | 3 +
windows/flutter/generated_plugins.cmake | 1 +
181 files changed, 23060 insertions(+), 2804 deletions(-)
create mode 100644 dist/download/.gitkeep
create mode 100644 dist/patch/.gitkeep
create mode 100644 docs/DRONE_STATION_OSD_CARD_USAGE.md
create mode 100644 docs/UAV_VIDEO_COMPLETION_SUMMARY.md
create mode 100644 docs/UAV_VIDEO_INTEGRATION_GUIDE.md
create mode 100644 docs/UAV_VIDEO_PAGE_INTEGRATION.md
create mode 100644 docs/UAV_VIDEO_USAGE_EXAMPLES.md
create mode 100644 lib/core/bluetooth/ble_manager.dart
create mode 100644 lib/core/bluetooth/ble_protocol_decoder.dart
create mode 100644 lib/core/bluetooth/bytes_util.dart
create mode 100644 lib/core/bluetooth/mc700_device_config.dart
create mode 100644 lib/core/bluetooth/protocol_parser.dart
create mode 100644 lib/core/services/device_permission_service.dart
create mode 100644 lib/features/v2/device_list/data/datasources/bind_device_datasource.dart
create mode 100644 lib/features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart
create mode 100644 lib/features/v2/device_list/data/repositories/bind_device_repository_impl.dart
create mode 100644 lib/features/v2/device_list/domain/entities/bind_device_entities.dart
create mode 100644 lib/features/v2/device_list/domain/repositories/bind_device_repository.dart
create mode 100644 lib/features/v2/device_list/domain/usecases/bind_device_usecases.dart
create mode 100644 lib/features/v2/device_list/domain/usecases/pause_flight_task_usecase.dart
create mode 100644 lib/features/v2/device_list/domain/usecases/return_home_usecase.dart
create mode 100644 lib/features/v2/device_list/presentation/cubit/bind_device_cubit.dart
create mode 100644 lib/features/v2/device_list/presentation/cubit/bind_device_state.dart
create mode 100644 lib/features/v2/device_list/presentation/pages/bind_device_page.dart
create mode 100644 lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart
create mode 100644 lib/features/v2/device_list/presentation/pages/qr_scanner_page.dart
create mode 100644 lib/features/v2/device_list/presentation/widgets/bluetooth_scan_modal.dart
create mode 100644 lib/features/v2/device_run_param/data/datasources/device_run_param_remote_datasource.dart
create mode 100644 lib/features/v2/device_run_param/data/datasources/device_run_param_remote_datasource_impl.dart
create mode 100644 lib/features/v2/device_run_param/data/models/device_run_param_model.dart
create mode 100644 lib/features/v2/device_run_param/data/repositories/device_run_param_repository_impl.dart
create mode 100644 lib/features/v2/device_run_param/domain/entities/device_run_param_entity.dart
create mode 100644 lib/features/v2/device_run_param/domain/repositories/device_run_param_repository.dart
create mode 100644 lib/features/v2/device_run_param/domain/usecases/device_run_param_usecases.dart
create mode 100644 lib/features/v2/device_run_param/presentation/pages/robot_param_settings_page.dart
create mode 100644 lib/features/v2/site/presentation/widgets/site_selector_widget.dart
create mode 100644 lib/features/v2/waring_center/data/datasources/alarm_dispatch_remote_datasource.dart
create mode 100644 lib/features/v2/waring_center/data/datasources/impl/alarm_dispatch_remote_datasource_impl.dart
create mode 100644 lib/features/v2/waring_center/data/models/alarm_handle_model.dart
create mode 100644 lib/features/v2/waring_center/data/repositories/alarm_dispatch_repository_impl.dart
create mode 100644 lib/features/v2/waring_center/domain/entities/alarm_dispatch_entity.dart
create mode 100644 lib/features/v2/waring_center/domain/usecases/dispatch_workorder_usecase.dart
create mode 100644 lib/features/v2/waring_center/domain/usecases/get_alarm_order_config_usecase.dart
create mode 100644 lib/features/v2/waring_center/presentation/cubit/alarm_dispatch_cubit.dart
create mode 100644 lib/features/v2/waring_center/presentation/pages/alarm_dispatch_page.dart
create mode 100644 lib/features/v2/waring_center/presentation/pages/alarm_handle_page.dart
create mode 100644 lib/features/v2/waring_center/presentation/states/alarm_dispatch_state.dart
create mode 100644 lib/features/v2/work_order/data/datasources/work_order_remote_datasource.dart
create mode 100644 lib/features/v2/work_order/data/datasources/work_order_remote_datasource_impl.dart
create mode 100644 lib/features/v2/work_order/data/models/work_order_model.dart
create mode 100644 lib/features/v2/work_order/data/repositories/work_order_repository_impl.dart
create mode 100644 lib/features/v2/work_order/di/work_order_di.dart
create mode 100644 lib/features/v2/work_order/domain/entities/work_order_entity.dart
create mode 100644 lib/features/v2/work_order/domain/repositories/work_order_repository.dart
create mode 100644 lib/features/v2/work_order/domain/usecases/work_order_usecases.dart
create mode 100644 lib/features/v2/work_order/presentation/cubit/work_order_cubit.dart
create mode 100644 lib/features/v2/work_order/presentation/states/work_order_state.dart
create mode 100644 lib/features/v2/work_order/work_order.dart
create mode 100644 lib/features/v2/workorder/domain/usecases/get_workorder_detail_usecase.dart
create mode 100644 lib/features/v2/workorder/domain/usecases/transfer_workorder_usecase.dart
create mode 100644 lib/features/v2/workorder/presentation/cubit/transfer_cubit.dart
create mode 100644 lib/features/v2/workorder/presentation/cubit/workorder_detail_cubit.dart
create mode 100644 lib/features/v2/workorder/presentation/pages/transfer_dialog.dart
create mode 100644 lib/features/v2/workorder/presentation/pages/workorder_detail_page.dart
create mode 100644 lib/features/v2/workorder/presentation/pages/workorder_on_site_page.dart
create mode 100644 lib/features/v2/workorder/presentation/pages/workorder_process_page.dart
create mode 100644 lib/features/v2/workorder/presentation/pages/workorder_receipt_page.dart
create mode 100644 lib/features/v2/workorder/presentation/pages/workorder_report_page.dart
create mode 100644 lib/features/v2/workorder/presentation/routes/workorder_routes.dart
create mode 100644 test_run.log
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index d4391a78..f26fd0ce 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,3 +1,6 @@
+import java.io.FileInputStream
+import java.util.Properties
+
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
+// 🔥 加载签名配置
+val keystorePropertiesFile = rootProject.file("key.properties")
+val keystoreProperties = Properties()
+if (keystorePropertiesFile.exists()) {
+ keystoreProperties.load(FileInputStream(keystorePropertiesFile))
+}
+
android {
namespace = "com.maibu.maibu_satabot_v2"
compileSdk = flutter.compileSdkVersion
@@ -30,11 +40,20 @@ android {
versionName = flutter.versionName
}
+ signingConfigs {
+ create("release") {
+ if (keystorePropertiesFile.exists()) {
+ storeFile = file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ }
+ }
+ }
+
buildTypes {
release {
- // TODO: Add your own signing config for the release build.
- // Signing with the debug keys for now, so `flutter run --release` works.
- signingConfig = signingConfigs.getByName("debug")
+ signingConfig = signingConfigs.getByName("release")
}
}
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 294527d6..62646408 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,14 @@
+
+
+
+
+
+
+
+
50% 绿色, 20-50% 橙色, <20% 红色 | 85% |
+| 温度 | 🌡️ thermostat | 蓝色 | 25°C |
+| 风速 | 💨 air | 绿色 | 3.5 m/s |
+| 降雨量 | 💧 water_drop | 蓝色 | 小雨 / 12 mm |
+| 网络状态 | 📶 network_check | 灰色 | 等级 4 |
+| GPS | 🛰️ gps_fixed | 紫色 | 12 颗 |
+
+## 📁 文件结构
+
+```
+lib/features/v2/device_list/
+├── presentation/
+│ ├── pages/
+│ │ └── drone_station_detail_page.dart # 详情页(集成 OSD 卡片)
+│ └── widgets/
+│ └── drone_station_osd_card.dart # OSD 实时数据卡片组件
+```
+
+## 🔧 技术实现
+
+### 1. MQTT 数据订阅
+
+```dart
+// 订阅机场 OSD topic
+final stationTopic = 'thing/product/$gatewaySn/osd';
+
+// 监听数据流
+_subscription = _dataSource.stationOsdStream.listen((osd) {
+ setState(() {
+ _currentOsd = osd;
+ _parseOsdFields(osd); // 解析并更新 UI
+ });
+});
+```
+
+### 2. 数据解析
+
+```dart
+void _parseOsdFields(DroneOsdEntity osd) {
+ final data = osd.rawData;
+
+ _osdFields = [
+ {
+ 'icon': Icons.battery_full_rounded,
+ 'label': '电量',
+ 'value': data['battery'] != null ? '${data['battery']}%' : '未知',
+ 'color': _getBatteryColor(data['battery']),
+ },
+ // ... 其他字段
+ ];
+}
+```
+
+### 3. 滑动卡片实现
+
+使用 `PageView.builder` 实现左右滑动:
+
+```dart
+PageView.builder(
+ itemCount: _osdFields.length,
+ onPageChanged: (index) {
+ setState(() {
+ _currentIndex = index;
+ });
+ },
+ itemBuilder: (context, index) {
+ final field = _osdFields[index];
+ return _buildOsdPage(field, index);
+ },
+)
+```
+
+## 🎨 UI 设计
+
+### 卡片布局
+```
+┌─────────────────────────────┐
+│ [图标] 电量 ◀ ▶ │
+│ │
+│ 85% │
+│ │
+│ 1 / 6 │
+│ │
+│ ● ○ ○ ○ ○ ○ │
+└─────────────────────────────┘
+```
+
+### 状态指示器
+- **当前页**:长条蓝色圆点(16px)
+- **其他页**:短条灰色圆点(6px)
+
+## 📊 数据映射规则
+
+### 降雨量格式化
+
+```dart
+String _formatRainfall(dynamic rainfall) {
+ if (rainfall is String) {
+ switch (rainfall.toLowerCase()) {
+ case 'no_rain': return '无降雨';
+ case 'light_rain': return '小雨';
+ case 'moderate_rain': return '中雨';
+ case 'heavy_rain': return '大雨';
+ default: return '$rainfall mm';
+ }
+ }
+ return '$rainfall mm';
+}
+```
+
+### 电量颜色规则
+
+```dart
+Color _getBatteryColor(dynamic battery) {
+ final value = battery is num ? battery.toDouble() : 0.0;
+ if (value > 50) return Color(0xFF00B42A); // 绿色
+ if (value > 20) return Color(0xFFFF7D00); // 橙色
+ return Color(0xFFF53F3F); // 红色
+}
+```
+
+## 🔄 生命周期管理
+
+### 启动监听
+```dart
+@override
+void initState() {
+ super.initState();
+ _startListening(); // 开始监听 MQTT
+}
+```
+
+### 停止监听
+```dart
+@override
+void dispose() {
+ _stopListening(); // 取消订阅,释放资源
+ super.dispose();
+}
+```
+
+### 动态更新
+```dart
+@override
+void didUpdateWidget(DroneStationOsdCard oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ // gatewaySn 或 isOnline 变化时重新订阅
+ if (oldWidget.gatewaySn != widget.gatewaySn ||
+ oldWidget.isOnline != widget.isOnline) {
+ _stopListening();
+ _startListening();
+ }
+}
+```
+
+## 🚀 使用方法
+
+### 在详情页中集成
+
+```dart
+DroneStationOsdCard(
+ gatewaySn: widget.station.gatewaySn,
+ isOnline: detail.isOnline,
+)
+```
+
+### 参数说明
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| `gatewaySn` | String | 机场网关序列号(用于 MQTT topic) |
+| `isOnline` | bool | 机场是否在线(决定是否显示实时数据) |
+
+## 📝 扩展建议
+
+### 1. 添加更多 OSD 字段
+
+在 `_parseOsdFields` 方法中添加新字段:
+
+```dart
+{
+ 'icon': Icons.new_icon,
+ 'label': '新字段名称',
+ 'value': data['newField'] != null ? '${data['newField']}' : '未知',
+ 'color': const Color(0xFF165DFF),
+},
+```
+
+### 2. 自定义滑动方向
+
+修改 `PageView` 的 `scrollDirection`:
+
+```dart
+PageView.builder(
+ scrollDirection: Axis.vertical, // 改为上下滑动
+ // ...
+)
+```
+
+### 3. 自动轮播
+
+添加定时器自动切换卡片:
+
+```dart
+Timer.periodic(Duration(seconds: 3), (timer) {
+ if (_osdFields.isNotEmpty) {
+ setState(() {
+ _currentIndex = (_currentIndex + 1) % _osdFields.length;
+ });
+ _pageController.animateToPage(_currentIndex, ...);
+ }
+});
+```
+
+### 4. 手势优化
+
+添加双击放大、长按复制等功能:
+
+```dart
+GestureDetector(
+ onDoubleTap: () {
+ // 双击放大
+ },
+ onLongPress: () {
+ // 长按复制数值
+ Clipboard.setData(ClipboardData(text: field['value']));
+ },
+ child: _buildOsdPage(field, index),
+)
+```
+
+## ⚠️ 注意事项
+
+1. **MQTT 连接**:确保应用已连接到 MQTT 服务器
+2. **topic 格式**:必须为 `thing/product/${gatewaySn}/osd`
+3. **数据格式**:MQTT 消息 payload 必须是 JSON 格式
+4. **内存管理**:页面销毁时必须取消订阅,避免内存泄漏
+5. **在线判断**:只有机场在线时才订阅 MQTT,离线时不订阅
+
+## 🐛 调试技巧
+
+### 查看 MQTT 日志
+
+```dart
+debugPrint('🔊 [DroneStationOsdCard] 开始监听机场 OSD: ${widget.gatewaySn}');
+debugPrint('✅ [DroneStationOsdCard] 收到机场 OSD 数据');
+```
+
+### 检查数据解析
+
+```dart
+debugPrint('📊 [DroneStationOsdCard] OSD 原始数据: ${osd.rawData}');
+debugPrint('📊 [DroneStationOsdCard] 解析后字段数: ${_osdFields.length}');
+```
+
+## 📦 依赖项
+
+- `flutter_bloc`: 状态管理
+- `mqtt_client`: MQTT 通信
+- `equatable`: 实体类比较
+- `get_it`: 依赖注入
+
+## ✅ 完成清单
+
+- [x] 创建 `DroneStationOsdCard` 组件
+- [x] 实现 MQTT 数据订阅
+- [x] 实现滑动窗口 UI
+- [x] 实现数据解析和格式化
+- [x] 集成到详情页
+- [x] 添加状态管理(在线/离线/加载中)
+- [x] 添加生命周期管理
+- [x] 添加调试日志
diff --git a/docs/UAV_VIDEO_COMPLETION_SUMMARY.md b/docs/UAV_VIDEO_COMPLETION_SUMMARY.md
new file mode 100644
index 00000000..4afa2eed
--- /dev/null
+++ b/docs/UAV_VIDEO_COMPLETION_SUMMARY.md
@@ -0,0 +1,195 @@
+# 无人机实时视频接口对接完成总结
+
+## ✅ 已完成工作
+
+### 1. API 层
+- [x] 添加 API 常量 `changeUAVLens` 到 `HttpApiConsts`
+
+### 2. 实体层 (Domain/Entities)
+- [x] 创建 `UavVideoStreamEntity` 实体类
+- [x] 定义 `UavLensType` 枚举(广角、变焦、红外)
+- [x] 定义 `VideoQualityType` 枚举(自适应、低、中、高清晰度)
+- [x] 实现 RTC 参数解析方法(appId、roomId、token、userId)
+
+### 3. 数据源层 (Data/Datasources)
+- [x] 扩展 `DroneStationDataSource` 接口,添加 `getUavVideoStream` 方法
+- [x] 实现 `DroneStationDataSourceImpl.getUavVideoStream`
+- [x] 支持可选的镜头类型参数
+- [x] 支持自定义清晰度和 Token 有效期
+- [x] 添加详细的日志输出用于调试
+
+### 4. 仓库层 (Data/Repositories)
+- [x] 扩展 `DroneStationRepository` 接口
+- [x] 实现 `DroneStationRepositoryImpl.getUavVideoStream`
+- [x] 使用 `Either` 模式处理错误
+
+### 5. 用例层 (Domain/UseCases)
+- [x] 创建 `GetUavVideoStreamUseCase`
+- [x] 封装业务逻辑,供 BLoC 调用
+- [x] 提供合理的默认值
+
+### 6. 状态管理层 (Presentation/BLoC)
+- [x] 添加 `UavVideoStreamLoad` 事件
+- [x] 添加 `UavVideoStreamLoading` 状态
+- [x] 添加 `UavVideoStreamLoaded` 状态
+- [x] 添加 `UavVideoStreamError` 状态
+- [x] 在 `DroneStationBloc` 中实现事件处理逻辑
+
+### 7. 依赖注入 (DI)
+- [x] 导入 `GetUavVideoStreamUseCase`
+- [x] 注册 `GetUavVideoStreamUseCase` 为单例
+- [x] 更新 `DroneStationBloc` 工厂,注入新的 UseCase
+
+### 8. UI 层 (Presentation/Pages)
+- [x] 创建示例页面 `UavLiveVideoPage`
+- [x] 实现镜头切换功能(PopupMenuButton)
+- [x] 实现加载状态显示
+- [x] 实现错误处理和重试机制
+- [x] 预留 RTC SDK 集成位置
+
+### 9. 测试
+- [x] 编写单元测试 `get_uav_video_stream_usecase_test.dart`
+- [x] 测试成功场景
+- [x] 测试失败场景
+- [x] 测试默认参数
+
+### 10. 文档
+- [x] 创建集成指南 `UAV_VIDEO_INTEGRATION_GUIDE.md`
+- [x] 创建使用示例 `UAV_VIDEO_USAGE_EXAMPLES.md`
+
+## 📁 文件清单
+
+### 新增文件(7个)
+1. `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart` - 实体类
+2. `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart` - 用例
+3. `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart` - 示例页面
+4. `test/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase_test.dart` - 单元测试
+5. `docs/UAV_VIDEO_INTEGRATION_GUIDE.md` - 集成指南
+6. `docs/UAV_VIDEO_USAGE_EXAMPLES.md` - 使用示例
+7. `docs/UAV_VIDEO_COMPLETION_SUMMARY.md` - 本文件
+
+### 修改文件(9个)
+1. `lib/core/consts/http_api_consts.dart` - 添加 API 常量
+2. `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
+3. `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
+4. `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
+5. `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
+6. `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
+7. `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
+8. `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
+9. `lib/core/di/injection.dart` - 注册依赖
+
+## 核心特性
+
+### 清洁架构设计
+- **分层清晰**:Entity → UseCase → Repository → DataSource → BLoC → UI
+- **依赖倒置**:高层模块不依赖低层模块的具体实现
+- **可测试性**:每层都可以独立测试
+
+### 可扩展性
+- **多镜头支持**:通过枚举轻松扩展更多镜头类型
+- **多清晰度支持**:支持自适应、低、中、高四种清晰度
+- **多 RTC SDK 支持**:兼容火山引擎和声网两种 RTC SDK
+- **可复用**:可在任何页面中使用,无需重复实现
+
+### 健壮性
+- **错误处理**:使用 `Either` 统一处理错误
+- **默认值**:提供合理的默认参数,简化调用
+- **日志记录**:详细的日志输出,方便调试
+- **重试机制**:支持手动重试和自动刷新
+
+## 快速开始
+
+### 方式一:直接使用示例页面
+```dart
+Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => UavLiveVideoPage(
+ droneSn: '1581F8HGX253U00A063U',
+ cameraIndex: '176-0-0',
+ ),
+ ),
+);
+```
+
+### 方式二:在自定义页面中集成
+参考 `docs/UAV_VIDEO_USAGE_EXAMPLES.md` 中的详细示例。
+
+## 📋 API 说明
+
+### 请求参数
+```json
+{
+ "sn": "1581F8HGX253U00A063U", // 必填:设备序列号
+ "lensType": "", // 可选:wide/zoom/ir
+ "cameraIndex": "176-0-0", // 必填:摄像头编号
+ "qualityType": "adaptive", // 可选:adaptive/low/medium/high
+ "videoExpire": 720000000 // 可选:Token有效期(毫秒)
+}
+```
+
+### 返回数据
+```json
+{
+ "msg": "操作成功",
+ "code": 200,
+ "data": {
+ "sn": "1581F8HGX253U00A063U",
+ "camera_index": "176-0-0",
+ "url": "app_id=xxx&room_id=xxx&token=xxx&user_id=xxx",
+ "expire_ts": 1781155234,
+ "url_type": "volc" // volc 或 agora
+ }
+}
+```
+
+## 🔧 后续工作
+
+1. [ ] 集成 RTC SDK 显示实际视频画面
+ - 根据 `urlType` 选择火山引擎或声网 SDK
+ - 初始化 RTC 引擎并加入房间
+ - 渲染远程视频流
+
+2. [ ] 添加视频录制功能
+ - 开始/停止录制
+ - 保存录制文件
+
+3. [ ] 添加截图功能
+ - 截取当前帧
+ - 保存到相册
+
+4. [ ] 优化视频加载超时处理
+ - 设置合理的超时时间
+ - 提供友好的超时提示
+
+5. [ ] 支持多路视频同时观看
+ - 分屏显示多个摄像头
+ - 切换主视图
+
+## 📚 相关文档
+
+- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
+- [使用示例](./UAV_VIDEO_USAGE_EXAMPLES.md)
+- [API 接口定义](../lib/core/consts/http_api_consts.dart)
+- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
+- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
+
+## ✅ 验证结果
+
+- [x] 所有文件编译通过,无错误
+- [x] 单元测试编写完成
+- [x] 依赖注入配置正确
+- [x] 文档齐全
+
+## 💡 注意事项
+
+1. **不要在页面上直接调用 API**:必须通过 BLoC 管理状态
+2. **及时释放资源**:在 `dispose()` 中关闭 BLoC
+3. **合理设置 Token 有效期**:建议设置为较长的时间,避免频繁刷新
+4. **检查 RTC 参数**:确保 AppId、RoomId、Token、UserId 正确解析
+5. **根据 urlType 选择 SDK**:volc 使用火山引擎,agora 使用声网
+
+## 🎉 总结
+
+无人机实时视频接口已成功对接,采用清洁架构设计,具有良好的可扩展性和可维护性。所有代码已通过编译检查,单元测试覆盖主要场景,文档齐全。下一步只需集成 RTC SDK 即可显示实际视频画面。
diff --git a/docs/UAV_VIDEO_INTEGRATION_GUIDE.md b/docs/UAV_VIDEO_INTEGRATION_GUIDE.md
new file mode 100644
index 00000000..ca35fd04
--- /dev/null
+++ b/docs/UAV_VIDEO_INTEGRATION_GUIDE.md
@@ -0,0 +1,323 @@
+# 无人机实时视频接口对接说明
+
+## 概述
+
+本模块实现了无人机实时视频流的获取功能,采用清洁架构设计,支持多种镜头类型切换(广角、变焦、红外),并兼容火山引擎和声网两种RTC SDK。
+
+## API 接口
+
+### 接口地址
+```
+POST http://1.95.137.212:59015/iot/UAV/changeLens
+```
+
+### 请求参数
+```json
+{
+ "sn": "1581F8HGX253U00A063U", // 无人机设备序列号(必填)
+ "lensType": "", // 镜头类型:wide(广角)、zoom(变焦)、ir(红外),可为空
+ "cameraIndex": "176-0-0", // 摄像头编号(必填)
+ "qualityType": "adaptive", // 清晰度:adaptive(自适应)、low、medium、high(默认adaptive)
+ "videoExpire": 720000000 // Token有效期(毫秒,默认720000000)
+}
+```
+
+### 返回数据
+```json
+{
+ "msg": "操作成功",
+ "code": 200,
+ "data": {
+ "sn": "1581F8HGX253U00A063U",
+ "camera_index": "176-0-0",
+ "url": "app_id=xxx&expire_time=xxx&room_id=xxx&token=xxx&user_id=xxx",
+ "expire_ts": 1781155234,
+ "url_type": "volc" // volc(火山引擎) 或 agora(声网)
+ }
+}
+```
+
+## 架构设计
+
+### 目录结构
+```
+lib/features/v2/device_list/
+├── domain/
+│ ├── entities/
+│ │ └── uav_video_stream_entity.dart # 实体类
+│ ├── repositories/
+│ │ ── drone_station_repository.dart # 仓库接口
+│ └── usecases/
+│ └── get_uav_video_stream_usecase.dart # 用例
+├── data/
+│ ├── datasources/
+│ │ ├── drone_station_datasource.dart # 数据源接口
+│ │ └── drone_station_datasource_impl.dart # 数据源实现
+│ └── repositories/
+│ └── drone_station_repository_impl.dart # 仓库实现
+── presentation/
+ ├── bloc/
+ │ ├── drone_station_bloc.dart # BLoC状态管理
+ │ ├── drone_station_event.dart # 事件定义
+ │ └── drone_station_state.dart # 状态定义
+ └── pages/
+ ── uav_live_video_page.dart # 示例页面
+```
+
+### 核心组件
+
+#### 1. 实体类 (Entity)
+**文件**: `domain/entities/uav_video_stream_entity.dart`
+
+定义了枚举类型和实体类:
+- `UavLensType`: 镜头类型枚举(wide、zoom、ir)
+- `VideoQualityType`: 视频质量枚举(adaptive、low、medium、high)
+- `UavVideoStreamEntity`: 视频流实体,包含解析RTC参数的方法
+
+#### 2. 数据源 (DataSource)
+**文件**: `data/datasources/drone_station_datasource_impl.dart`
+
+实现了API调用逻辑:
+- 发送POST请求到 `/iot/UAV/changeLens`
+- 处理响应并转换为实体对象
+- 包含详细的日志输出用于调试
+
+#### 3. 仓库 (Repository)
+**文件**: `data/repositories/drone_station_repository_impl.dart`
+
+使用 `fpdart` 的 `Either` 类型处理错误:
+- 成功时返回 `Right(UavVideoStreamEntity)`
+- 失败时返回 `Left(Failure)`
+
+#### 4. 用例 (UseCase)
+**文件**: `domain/usecases/get_uav_video_stream_usecase.dart`
+
+封装业务逻辑,供BLoC调用。
+
+#### 5. BLoC 状态管理
+**文件**: `presentation/bloc/drone_station_bloc.dart`
+
+新增事件和状态:
+- **事件**: `UavVideoStreamLoad` - 加载视频流
+- **状态**:
+ - `UavVideoStreamLoading` - 加载中
+ - `UavVideoStreamLoaded` - 加载成功
+ - `UavVideoStreamError` - 加载失败
+
+#### 6. 依赖注入
+**文件**: `core/di/injection.dart`
+
+已注册以下单例:
+```dart
+sl.registerLazySingleton(
+ () => GetUavVideoStreamUseCase(sl()),
+);
+sl.registerFactory(
+ () => DroneStationBloc(sl(), sl(), sl(), sl()),
+);
+```
+
+## 使用方式
+
+### 方式一:直接使用示例页面
+
+```dart
+import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
+
+Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => UavLiveVideoPage(
+ droneSn: '1581F8HGX253U00A063U',
+ cameraIndex: '176-0-0',
+ ),
+ ),
+);
+```
+
+### 方式二:在现有页面中使用
+
+#### 1. 导入必要的类
+```dart
+import 'package:flutter_bloc/flutter_bloc.dart';
+import '../../../../../core/di/injection.dart';
+import '../../domain/entities/uav_video_stream_entity.dart';
+import '../bloc/drone_station_bloc.dart';
+import '../bloc/drone_station_event.dart';
+import '../bloc/drone_station_state.dart';
+```
+
+#### 2. 初始化 BLoC
+```dart
+late DroneStationBloc _bloc;
+
+@override
+void initState() {
+ super.initState();
+ _bloc = sl();
+ // 加载视频流
+ _loadVideoStream(UavLensType.wide);
+}
+
+@override
+void dispose() {
+ _bloc.close();
+ super.dispose();
+}
+```
+
+#### 3. 加载视频流
+```dart
+void _loadVideoStream(UavLensType lensType) {
+ setState(() {
+ _isLoading = true;
+ _errorMessage = null;
+ });
+
+ _bloc.add(
+ UavVideoStreamLoad(
+ sn: widget.droneSn,
+ cameraIndex: widget.cameraIndex,
+ lensType: lensType,
+ qualityType: VideoQualityType.adaptive,
+ videoExpire: 720000000,
+ ),
+ );
+}
+```
+
+#### 4. 监听状态变化
+```dart
+BlocConsumer(
+ listener: (context, state) {
+ if (state is UavVideoStreamLoaded) {
+ final videoStream = state.videoStream;
+ debugPrint('URL Type: ${videoStream.urlType}');
+ debugPrint('AppId: ${videoStream.appId}');
+ debugPrint('RoomId: ${videoStream.roomId}');
+ debugPrint('UserId: ${videoStream.userId}');
+
+ // TODO: 根据 urlType 初始化对应的 RTC 引擎
+ if (videoStream.urlType.toLowerCase() == 'volc') {
+ // 使用火山引擎 RTC SDK
+ } else if (videoStream.urlType.toLowerCase() == 'agora') {
+ // 使用声网 RTC SDK
+ }
+ } else if (state is UavVideoStreamError) {
+ setState(() {
+ _errorMessage = state.message;
+ });
+ }
+ },
+ builder: (context, state) {
+ // 根据状态渲染UI
+ if (state is UavVideoStreamLoading) {
+ return const CircularProgressIndicator();
+ }
+
+ if (state is UavVideoStreamLoaded) {
+ // 显示视频画面
+ return Container();
+ }
+
+ if (state is UavVideoStreamError) {
+ return Text('错误: ${state.message}');
+ }
+
+ return Container();
+ },
+)
+```
+
+#### 5. 切换镜头类型
+```dart
+// 切换到广角镜头
+_loadVideoStream(UavLensType.wide);
+
+// 切换到变焦镜头
+_loadVideoStream(UavLensType.zoom);
+
+// 切换到红外镜头
+_loadVideoStream(UavLensType.ir);
+```
+
+## RTC 集成
+
+获取到视频流后,需要根据 `urlType` 选择对应的 RTC SDK:
+
+### 火山引擎 (volc)
+```dart
+final appId = videoStream.appId;
+final roomId = videoStream.roomId;
+final token = videoStream.token;
+final userId = videoStream.userId;
+
+// 使用 volc_engine_rtc SDK
+final engine = await RTCEngine.createRTCEngine(
+ RTCVideoContext(appId: appId, eventHandler: handler),
+);
+final room = await engine.createRTCRoom(roomId);
+await room.joinRoom(token: token, userId: userId);
+```
+
+### 声网 (agora)
+```dart
+final appId = videoStream.appId;
+final channelId = videoStream.roomId;
+final token = videoStream.token;
+final uid = int.tryParse(videoStream.userId) ?? 0;
+
+// 使用 agora_rtc_engine SDK
+final engine = createAgoraRtcEngine();
+await engine.initialize(RtcEngineContext(appId: appId));
+await engine.joinChannel(
+ token: token,
+ channelId: channelId,
+ uid: uid,
+ options: ChannelMediaOptions(...),
+);
+```
+
+## 注意事项
+
+1. **可扩展性**: 本模块采用清洁架构设计,所有业务逻辑与UI分离,便于在其他页面复用。
+
+2. **错误处理**: 使用 `Either` 模式统一处理错误,确保异常不会直接抛出。
+
+3. **日志记录**: 数据源层包含详细的日志输出,方便调试和问题排查。
+
+4. **默认值**:
+ - `lensType` 可为空,后端会根据实际情况选择默认镜头
+ - `qualityType` 默认为 `adaptive`(自适应)
+ - `videoExpire` 默认为 `720000000` 毫秒
+
+5. **Token 有效期**: `videoExpire` 参数单位为毫秒,建议设置较长的有效期以避免频繁刷新。
+
+6. **多镜头支持**: 通过 `UavLensType` 枚举可以轻松扩展更多镜头类型。
+
+## 后续工作
+
+1. [ ] 集成 RTC SDK 显示实际视频画面
+2. [ ] 添加视频录制功能
+3. [ ] 添加截图功能
+4. [ ] 优化视频加载超时处理
+5. [ ] 添加视频质量切换功能
+6. [ ] 支持多路视频同时观看
+
+## 相关文件清单
+
+### 新增文件
+- `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart`
+- `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart`
+- `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart`
+
+### 修改文件
+- `lib/core/consts/http_api_consts.dart` - 添加 API 常量
+- `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
+- `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
+- `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
+- `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
+- `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
+- `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
+- `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
+- `lib/core/di/injection.dart` - 注册依赖
diff --git a/docs/UAV_VIDEO_PAGE_INTEGRATION.md b/docs/UAV_VIDEO_PAGE_INTEGRATION.md
new file mode 100644
index 00000000..ddb42337
--- /dev/null
+++ b/docs/UAV_VIDEO_PAGE_INTEGRATION.md
@@ -0,0 +1,298 @@
+# 无人机视频页面集成完成
+
+## 📋 更新内容
+
+已将新的无人机实时视频接口集成到现有的**无人机视频回传/远程控制**页面中。
+
+---
+
+## 🎯 集成位置
+
+### 1. 无人机详情页面 → 无人机状态卡片
+
+**文件**: `lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart`
+
+**修改内容**:
+```dart
+// 点击"无人机状态"卡片时,传递设备序列号和摄像头索引
+Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => DroneVideoControlPage(
+ droneSn: detail.deviceSn, // ✅ 传递设备序列号
+ cameraIndex: detail.gatewayCameraList != null &&
+ detail.gatewayCameraList!.isNotEmpty
+ ? detail.gatewayCameraList!.first.cameraIndex
+ : '176-0-0', // 默认值
+ ),
+ ),
+);
+```
+
+---
+
+### 2. 无人机视频控制页面
+
+**文件**: `lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart`
+
+**新增功能**:
+
+#### ✅ 接收参数
+```dart
+class DroneVideoControlPage extends StatefulWidget {
+ final String droneSn; // 无人机设备序列号
+ final String cameraIndex; // 摄像头编号
+
+ const DroneVideoControlPage({
+ super.key,
+ required this.droneSn,
+ required this.cameraIndex,
+ });
+}
+```
+
+#### ✅ 自动加载视频流
+```dart
+@override
+void initState() {
+ super.initState();
+ _bloc = sl();
+ // 默认加载广角镜头的视频流
+ _loadVideoStream(UavLensType.wide);
+}
+```
+
+#### ✅ 镜头切换功能
+在视频播放器右下角添加了镜头类型选择器:
+- **广角** (wide)
+- **变焦** (zoom)
+- **红外** (ir)
+
+点击可快速切换不同镜头的视频流。
+
+#### ✅ 加载状态显示
+```
+┌─────────────────────────┐
+│ │
+│ ⏳ 正在加载... │
+│ │
+└─────────────────────────┘
+```
+
+#### ✅ 错误处理和重试
+```
+┌─────────────────────────┐
+│ │
+│ ❌ 加载失败 │
+│ 错误信息: xxxxx │
+│ │
+│ [ 重试 ] │
+│ │
+└─────────────────────────┘
+```
+
+---
+
+## 🔄 工作流程
+
+```mermaid
+graph LR
+ A[无人机详情页] --> B[点击无人机状态卡片]
+ B --> C[打开视频控制页面]
+ C --> D[自动调用changeLens接口]
+ D --> E{加载结果}
+ E -->|成功| F[显示视频画面]
+ E -->|失败| G[显示错误+重试按钮]
+ F --> H[用户切换镜头]
+ H --> D
+ G -->|点击重试| D
+```
+
+---
+
+## 📱 UI 展示
+
+### 视频播放器区域
+
+```
+┌──────────────────────────────────────┐
+│ ● REC 00:12:36 │
+│ │
+│ │
+│ [视频画面/加载状态] │
+│ │
+│ │
+│ ┌────────┐ │
+│ │广角 ▼│ │ ← 镜头切换
+│ └────────┘ │
+└──────────────────────────────────────┘
+```
+
+### 镜头切换菜单
+
+```
+┌──────────┐
+│ 广角 │ ← 当前选中
+│ 变焦 │
+│ 红外 │
+└──────────┘
+```
+
+---
+
+## 🔧 技术实现
+
+### 1. BLoC 状态管理
+```dart
+// 监听视频流状态
+BlocConsumer(
+ listener: (context, state) {
+ if (state is UavVideoStreamLoaded) {
+ // 获取视频流数据
+ _videoStream = state.videoStream;
+
+ // TODO: 初始化 RTC 引擎并显示视频
+ debugPrint('AppId: ${state.videoStream.appId}');
+ debugPrint('RoomId: ${state.videoStream.roomId}');
+ debugPrint('UserId: ${state.videoStream.userId}');
+ } else if (state is UavVideoStreamError) {
+ // 显示错误信息
+ _errorMessage = state.message;
+ }
+ },
+ // ...
+)
+```
+
+### 2. 视频流加载
+```dart
+void _loadVideoStream(UavLensType lensType) {
+ setState(() {
+ _isLoading = true;
+ _errorMessage = null;
+ _currentLensType = lensType;
+ });
+
+ _bloc.add(
+ UavVideoStreamLoad(
+ sn: widget.droneSn,
+ cameraIndex: widget.cameraIndex,
+ lensType: lensType,
+ qualityType: VideoQualityType.adaptive,
+ videoExpire: 720000000,
+ ),
+ );
+}
+```
+
+### 3. 镜头切换
+```dart
+PopupMenuButton(
+ icon: Text(_getLensTypeName(_currentLensType)),
+ onSelected: (UavLensType lensType) {
+ if (lensType != _currentLensType) {
+ _loadVideoStream(lensType); // 重新加载视频流
+ }
+ },
+ itemBuilder: (context) => [
+ PopupMenuItem(value: UavLensType.wide, child: Text('广角')),
+ PopupMenuItem(value: UavLensType.zoom, child: Text('变焦')),
+ PopupMenuItem(value: UavLensType.ir, child: Text('红外')),
+ ],
+)
+```
+
+---
+
+## ✅ 已完成的功能
+
+- ✅ 从无人机详情页传递设备序列号和摄像头索引
+- ✅ 自动调用 `changeLens` 接口获取视频流
+- ✅ 支持三种镜头类型切换(广角/变焦/红外)
+- ✅ 加载状态显示
+- ✅ 错误处理和重试机制
+- ✅ 打印视频流参数(AppId、RoomId、UserId等)
+- ✅ 清洁架构设计,易于扩展和维护
+
+---
+
+## 🔜 下一步工作(TODO)
+
+### 1. 集成 RTC SDK 显示实际视频
+
+在 `_buildVideoPlayer()` 方法中,当 `_videoStream != null` 时:
+
+```dart
+if (_videoStream != null) {
+ // 根据 urlType 选择对应的 RTC SDK
+ if (_videoStream!.urlType == 'volc') {
+ // 使用火山引擎 RTC SDK
+ return VolcEngineRtcView(
+ appId: _videoStream!.appId,
+ roomId: _videoStream!.roomId,
+ token: _videoStream!.token,
+ userId: _videoStream!.userId,
+ );
+ } else if (_videoStream!.urlType == 'agora') {
+ // 使用声网 RTC SDK
+ return AgoraRtcView(
+ appId: _videoStream!.appId,
+ channelId: _videoStream!.roomId,
+ token: _videoStream!.token,
+ uid: int.parse(_videoStream!.userId),
+ );
+ }
+}
+```
+
+### 2. 参考现有实现
+
+可以参考 `drone_station_detail_page.dart` 中的以下方法:
+- `_initFloatingVolcEngine()` - 火山引擎初始化
+- `_initFloatingAgoraEngine()` - 声网引擎初始化
+- `_disposeVolcEngine()` / `_disposeAgoraEngine()` - 资源释放
+
+### 3. 添加更多控制功能
+
+- 云台控制(上下左右)
+- 变焦倍数调节
+- 拍照/录像
+- AI识别结果展示
+
+---
+
+## 📝 测试步骤
+
+1. **进入无人机详情页**
+ - 确保无人机在线且有摄像头列表
+
+2. **点击"无人机状态"卡片**
+ - 应该跳转到视频控制页面
+
+3. **观察加载过程**
+ - 看到 "正在加载视频流..." 提示
+ - 成功后显示视频画面(当前为占位图)
+
+4. **测试镜头切换**
+ - 点击右下角的镜头类型按钮
+ - 选择"广角"、"变焦"或"红外"
+ - 观察是否重新加载并切换成功
+
+5. **测试错误处理**
+ - 断开网络连接
+ - 应该显示错误信息和重试按钮
+ - 点击重试应该重新加载
+
+---
+
+## 🎉 总结
+
+✅ **集成完成!**
+
+现在无人机视频控制页面已经:
+- 正确接收设备参数
+- 自动调用新的视频流接口
+- 支持多镜头切换
+- 完善的加载和错误处理
+
+只需最后一步:**集成 RTC SDK 显示实际视频画面**,即可完整实现无人机实时视频监控功能!
diff --git a/docs/UAV_VIDEO_USAGE_EXAMPLES.md b/docs/UAV_VIDEO_USAGE_EXAMPLES.md
new file mode 100644
index 00000000..39406c70
--- /dev/null
+++ b/docs/UAV_VIDEO_USAGE_EXAMPLES.md
@@ -0,0 +1,642 @@
+# 无人机实时视频接口使用示例
+
+## 快速开始
+
+本指南展示如何在你的项目中快速集成无人机实时视频功能。
+
+## 1. 基础用法 - 直接导航到视频页面
+
+最简单的方式是直接导航到 `UavLiveVideoPage`:
+
+```dart
+import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
+
+// 在任意地方调用
+void _openVideo() {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => UavLiveVideoPage(
+ droneSn: '1581F8HGX253U00A063U', // 无人机序列号
+ cameraIndex: '176-0-0', // 摄像头编号
+ ),
+ ),
+ );
+}
+```
+
+## 2. 高级用法 - 在自定义页面中集成
+
+如果你需要在自己的页面中集成视频功能,可以按照以下步骤操作:
+
+### 步骤 1: 创建页面并初始化 BLoC
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import '../../../../../core/di/injection.dart';
+import '../../domain/entities/uav_video_stream_entity.dart';
+import '../bloc/drone_station_bloc.dart';
+import '../bloc/drone_station_event.dart';
+import '../bloc/drone_station_state.dart';
+
+class MyCustomVideoPage extends StatefulWidget {
+ final String droneSn;
+ final String cameraIndex;
+
+ const MyCustomVideoPage({
+ super.key,
+ required this.droneSn,
+ required this.cameraIndex,
+ });
+
+ @override
+ State createState() => _MyCustomVideoPageState();
+}
+
+class _MyCustomVideoPageState extends State {
+ late DroneStationBloc _bloc;
+ UavVideoStreamEntity? _videoStream;
+ bool _isLoading = false;
+ String? _errorMessage;
+ UavLensType? _currentLensType;
+
+ @override
+ void initState() {
+ super.initState();
+ _bloc = sl();
+ // 默认加载广角镜头
+ _loadVideoStream(UavLensType.wide);
+ }
+
+ @override
+ void dispose() {
+ _bloc.close();
+ super.dispose();
+ }
+}
+```
+
+### 步骤 2: 实现加载视频流方法
+
+```dart
+/// 加载视频流
+void _loadVideoStream(UavLensType lensType) {
+ setState(() {
+ _isLoading = true;
+ _errorMessage = null;
+ _currentLensType = lensType;
+ });
+
+ _bloc.add(
+ UavVideoStreamLoad(
+ sn: widget.droneSn,
+ cameraIndex: widget.cameraIndex,
+ lensType: lensType,
+ qualityType: VideoQualityType.adaptive, // 自适应清晰度
+ videoExpire: 720000000, // Token有效期(毫秒)
+ ),
+ );
+}
+```
+
+### 步骤 3: 构建 UI 并监听状态变化
+
+```dart
+@override
+Widget build(BuildContext context) {
+ return BlocProvider.value(
+ value: _bloc,
+ child: Scaffold(
+ backgroundColor: Colors.black,
+ appBar: AppBar(
+ backgroundColor: Colors.black,
+ elevation: 0,
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back, color: Colors.white),
+ onPressed: () => Navigator.pop(context),
+ ),
+ title: const Text(
+ '无人机实时视频',
+ style: TextStyle(color: Colors.white),
+ ),
+ centerTitle: true,
+ actions: [
+ // 镜头切换按钮
+ PopupMenuButton(
+ icon: const Icon(Icons.videocam, color: Colors.white),
+ tooltip: '切换镜头',
+ onSelected: (lensType) {
+ _loadVideoStream(lensType);
+ },
+ itemBuilder: (context) => [
+ const PopupMenuItem(
+ value: UavLensType.wide,
+ child: Row(
+ children: [
+ Icon(Icons.videocam, size: 20),
+ SizedBox(width: 8),
+ Text('广角镜头'),
+ ],
+ ),
+ ),
+ const PopupMenuItem(
+ value: UavLensType.zoom,
+ child: Row(
+ children: [
+ Icon(Icons.zoom_in, size: 20),
+ SizedBox(width: 8),
+ Text('变焦镜头'),
+ ],
+ ),
+ ),
+ const PopupMenuItem(
+ value: UavLensType.ir,
+ child: Row(
+ children: [
+ Icon(Icons.thermostat, size: 20),
+ SizedBox(width: 8),
+ Text('红外镜头'),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ body: BlocConsumer(
+ listener: (context, state) {
+ // 监听状态变化
+ if (state is UavVideoStreamLoaded) {
+ setState(() {
+ _videoStream = state.videoStream;
+ _isLoading = false;
+ });
+
+ debugPrint('=== 视频流加载成功 ===');
+ debugPrint('URL Type: ${state.videoStream.urlType}');
+ debugPrint('AppId: ${state.videoStream.appId}');
+ debugPrint('RoomId: ${state.videoStream.roomId}');
+ debugPrint('UserId: ${state.videoStream.userId}');
+
+ // TODO: 这里可以初始化 RTC 引擎并显示视频
+ _initRtcEngine(state.videoStream);
+ } else if (state is UavVideoStreamError) {
+ setState(() {
+ _errorMessage = state.message;
+ _isLoading = false;
+ });
+ }
+ },
+ builder: (context, state) {
+ // 根据状态渲染不同的 UI
+
+ // 加载中状态
+ if (_isLoading || state is UavVideoStreamLoading) {
+ return const Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ CircularProgressIndicator(color: Colors.white),
+ SizedBox(height: 16),
+ Text(
+ '正在加载视频流...',
+ style: TextStyle(color: Colors.white),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 错误状态
+ if (_errorMessage != null || state is UavVideoStreamError) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(
+ Icons.error_outline,
+ size: 48,
+ color: Colors.red,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ _errorMessage ?? (state as UavVideoStreamError).message,
+ style: const TextStyle(color: Colors.white),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton(
+ onPressed: () {
+ if (_currentLensType != null) {
+ _loadVideoStream(_currentLensType!);
+ }
+ },
+ child: const Text('重试'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ // 无视频信号
+ if (_videoStream == null) {
+ return const Center(
+ child: Text(
+ '暂无视频信号',
+ style: TextStyle(color: Colors.white),
+ ),
+ );
+ }
+
+ // 视频已加载,显示视频画面
+ return Container(
+ width: double.infinity,
+ height: double.infinity,
+ color: Colors.black,
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(
+ Icons.videocam_off,
+ size: 64,
+ color: Colors.grey,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ '视频流已获取\nURL Type: ${_videoStream!.urlType}\nCamera: $_currentLensType',
+ style: const TextStyle(color: Colors.white),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ '请集成 RTC SDK 后在此处显示视频画面',
+ style: TextStyle(
+ color: Colors.grey[600],
+ fontSize: 12,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ );
+}
+```
+
+### 步骤 4: 初始化 RTC 引擎(可选)
+
+```dart
+/// 初始化 RTC 引擎
+Future _initRtcEngine(UavVideoStreamEntity videoStream) async {
+ final appId = videoStream.appId;
+ final roomId = videoStream.roomId;
+ final token = videoStream.token;
+ final userId = videoStream.userId.isNotEmpty
+ ? videoStream.userId
+ : 'user_${DateTime.now().millisecondsSinceEpoch}';
+
+ if (appId.isEmpty || roomId.isEmpty || token.isEmpty) {
+ debugPrint('RTC 参数缺失');
+ return;
+ }
+
+ debugPrint('=== RTC 初始化 ===');
+ debugPrint('AppId: $appId');
+ debugPrint('RoomId: $roomId');
+ debugPrint('UserId: $userId');
+ debugPrint('URL Type: ${videoStream.urlType}');
+
+ // 根据 urlType 选择不同的 RTC SDK
+ final sdkType = videoStream.urlType.toLowerCase() == 'agora'
+ ? RtcSdkType.agora
+ : RtcSdkType.volcengine;
+
+ if (sdkType == RtcSdkType.agora) {
+ await _initAgoraEngine(appId, roomId, token, userId);
+ } else {
+ await _initVolcEngine(appId, roomId, token, userId);
+ }
+}
+
+// TODO: 实现具体的 RTC 引擎初始化逻辑
+// 参考 drone_station_detail_page.dart 中的实现
+```
+
+## 3. 常见场景示例
+
+### 场景 1: 从列表页跳转到视频页
+
+```dart
+// 在设备列表中点击某个设备
+void _onDeviceTap(Device device) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => UavLiveVideoPage(
+ droneSn: device.sn,
+ cameraIndex: device.cameraIndex,
+ ),
+ ),
+ );
+}
+```
+
+### 场景 2: 支持多个摄像头切换
+
+```dart
+class MultiCameraVideoPage extends StatefulWidget {
+ final String droneSn;
+ final List cameraIndices;
+
+ const MultiCameraVideoPage({
+ super.key,
+ required this.droneSn,
+ required this.cameraIndices,
+ });
+
+ @override
+ State createState() => _MultiCameraVideoPageState();
+}
+
+class _MultiCameraVideoPageState extends State {
+ late DroneStationBloc _bloc;
+ int _currentCameraIndex = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ _bloc = sl();
+ _loadCurrentCamera();
+ }
+
+ void _loadCurrentCamera() {
+ _bloc.add(
+ UavVideoStreamLoad(
+ sn: widget.droneSn,
+ cameraIndex: widget.cameraIndices[_currentCameraIndex],
+ lensType: UavLensType.wide,
+ ),
+ );
+ }
+
+ void _switchCamera(int index) {
+ setState(() {
+ _currentCameraIndex = index;
+ });
+ _loadCurrentCamera();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text('摄像头 ${_currentCameraIndex + 1}/${widget.cameraIndices.length}'),
+ actions: [
+ // 切换摄像头按钮
+ IconButton(
+ icon: const Icon(Icons.switch_camera),
+ onPressed: () {
+ final nextIndex = (_currentCameraIndex + 1) % widget.cameraIndices.length;
+ _switchCamera(nextIndex);
+ },
+ ),
+ ],
+ ),
+ body: BlocBuilder(
+ builder: (context, state) {
+ // ... 根据状态渲染UI
+ },
+ ),
+ );
+ }
+}
+```
+
+### 场景 3: 自动刷新视频流(Token 过期时)
+
+```dart
+class AutoRefreshVideoPage extends StatefulWidget {
+ final String droneSn;
+ final String cameraIndex;
+
+ const AutoRefreshVideoPage({
+ super.key,
+ required this.droneSn,
+ required this.cameraIndex,
+ });
+
+ @override
+ State createState() => _AutoRefreshVideoPageState();
+}
+
+class _AutoRefreshVideoPageState extends State {
+ late DroneStationBloc _bloc;
+ Timer? _refreshTimer;
+ static const _tokenRefreshInterval = Duration(minutes: 55); // 每55分钟刷新一次(Token有效期约1小时)
+
+ @override
+ void initState() {
+ super.initState();
+ _bloc = sl();
+ _loadVideoStream();
+
+ // 启动定时刷新
+ _startAutoRefresh();
+ }
+
+ void _startAutoRefresh() {
+ _refreshTimer?.cancel();
+ _refreshTimer = Timer.periodic(_tokenRefreshInterval, (timer) {
+ debugPrint('⏰ 自动刷新视频流 Token');
+ _loadVideoStream();
+ });
+ }
+
+ void _loadVideoStream() {
+ _bloc.add(
+ UavVideoStreamLoad(
+ sn: widget.droneSn,
+ cameraIndex: widget.cameraIndex,
+ lensType: UavLensType.wide,
+ ),
+ );
+ }
+
+ @override
+ void dispose() {
+ _refreshTimer?.cancel();
+ _bloc.close();
+ super.dispose();
+ }
+}
+```
+
+## 4. 最佳实践
+
+### ✅ 推荐做法
+
+1. **始终检查错误状态**
+ ```dart
+ if (state is UavVideoStreamError) {
+ // 显示友好的错误提示
+ showSnackBar(context, '视频加载失败: ${state.message}');
+ }
+ ```
+
+2. **提供重试机制**
+ ```dart
+ ElevatedButton(
+ onPressed: () => _loadVideoStream(_currentLensType!),
+ child: const Text('重试'),
+ )
+ ```
+
+3. **记录关键日志**
+ ```dart
+ debugPrint('视频流加载成功: URL Type=${videoStream.urlType}');
+ ```
+
+4. **合理设置 Token 有效期**
+ ```dart
+ videoExpire: 720000000, // 约8天,避免频繁刷新
+ ```
+
+5. **及时释放资源**
+ ```dart
+ @override
+ void dispose() {
+ _bloc.close();
+ super.dispose();
+ }
+ ```
+
+### ❌ 避免的做法
+
+1. **不要在页面外直接调用 API**
+ ```dart
+ // 错误:绕过 BLoC 直接调用
+ final result = await repository.getUavVideoStream(...);
+
+ // ✅ 正确:通过 BLoC 管理状态
+ _bloc.add(UavVideoStreamLoad(...));
+ ```
+
+2. **不要忘记处理加载状态**
+ ```dart
+ // ❌ 错误:没有加载指示器
+ if (state is UavVideoStreamLoaded) { ... }
+
+ // ✅ 正确:显示加载状态
+ if (state is UavVideoStreamLoading) {
+ return CircularProgressIndicator();
+ }
+ ```
+
+3. **不要硬编码参数**
+ ```dart
+ // 错误:硬编码
+ sn: '1581F8HGX253U00A063U',
+
+ // ✅ 正确:使用变量
+ sn: widget.droneSn,
+ ```
+
+## 5. 故障排查
+
+### 问题 1: 视频流加载失败
+
+**可能原因:**
+- 网络问题
+- 设备序列号错误
+- 摄像头编号错误
+- Token 过期
+
+**解决方案:**
+1. 检查网络连接
+2. 验证 `droneSn` 和 `cameraIndex` 是否正确
+3. 查看控制台日志输出
+4. 尝试重新加载
+
+### 问题 2: 视频画面不显示
+
+**可能原因:**
+- RTC SDK 未正确初始化
+- RTC 参数解析失败
+- SDK 版本不兼容
+
+**解决方案:**
+1. 检查 `urlType` 字段,确认使用正确的 SDK
+2. 验证 AppId、RoomId、Token、UserId 是否正确解析
+3. 查看 RTC SDK 的日志输出
+4. 参考 `drone_station_detail_page.dart` 中的实现
+
+### 问题 3: 镜头切换无效
+
+**可能原因:**
+- 后端不支持该镜头类型
+- 摄像头不支持指定的镜头
+
+**解决方案:**
+1. 检查后端返回的错误信息
+2. 尝试其他镜头类型
+3. 联系后端确认支持的镜头类型
+
+## 6. 扩展功能
+
+### 添加视频录制功能
+
+```dart
+// TODO: 集成 RTC SDK 的录制功能
+Future _startRecording() async {
+ // 根据使用的 RTC SDK 调用相应的录制 API
+}
+
+Future _stopRecording() async {
+ // 停止录制并保存文件
+}
+```
+
+### 添加截图功能
+
+```dart
+// TODO: 集成 RTC SDK 的截图功能
+Future _takeScreenshot() async {
+ // 根据使用的 RTC SDK 调用相应的截图 API
+ // 保存截图到相册
+}
+```
+
+### 添加视频质量切换
+
+```dart
+void _changeQuality(VideoQualityType quality) {
+ _bloc.add(
+ UavVideoStreamLoad(
+ sn: widget.droneSn,
+ cameraIndex: widget.cameraIndex,
+ lensType: _currentLensType,
+ qualityType: quality, // 切换清晰度
+ ),
+ );
+}
+```
+
+## 7. 相关文档
+
+- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
+- [API 接口文档](../lib/core/consts/http_api_consts.dart)
+- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
+- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
+
+## 8. 技术支持
+
+如遇到问题,请:
+1. 查看控制台日志输出
+2. 参考示例代码 `uav_live_video_page.dart`
+3. 查阅集成指南文档
+4. 联系开发团队
diff --git a/lib/components/device_status_modal.dart b/lib/components/device_status_modal.dart
index 1ceb98a6..411be35a 100644
--- a/lib/components/device_status_modal.dart
+++ b/lib/components/device_status_modal.dart
@@ -15,7 +15,7 @@ import '../features/devices/presentation/bloc/devices_cubit.dart';
import '../features/remote_control/presentation/bloc/remote_control_cubit.dart';
import '../features/remote_control/presentation/bloc/remote_control_state.dart';
-const int DATA_TIMEOUT_SECONDS = 5;
+const int DATA_TIMEOUT_SECONDS = 15;
class DeviceStatusModal extends StatefulWidget {
const DeviceStatusModal({super.key});
@@ -162,7 +162,7 @@ class _DeviceStatusModalState extends State {
}
Widget _buildCardContentView(DeviceStatusState state) {
- if (_isDataTimeout || state is DeviceStatusInitial) {
+ if (state is DeviceStatusInitial) {
return _noDataWidget();
}
@@ -445,11 +445,9 @@ class _DeviceStatusModalState extends State {
Widget build(BuildContext context) {
return BlocBuilder(
builder: (context, state) {
- if (!_isDataTimeout && state is DeviceStatusUpdated) {
- _startDataTimeoutTimer();
- if (_isDataTimeout) {
- setState(() => _isDataTimeout = false);
- }
+ if (state is DeviceStatusUpdated) {
+ _startDataTimeoutTimer(); // 每次收到数据重置超时计时器
+ _isDataTimeout = false; // 直接赋值,不调 setState(build 阶段禁止)
_appendChartData(state);
}
@@ -527,7 +525,7 @@ class _DeviceStatusModalState extends State {
String satelliteCnt = _isDataTimeout ? '--' : '--';
String headingStatus = _isDataTimeout ? "--" : "--";
- if (!_isDataTimeout && state is DeviceStatusUpdated) {
+ if (state is DeviceStatusUpdated) {
headingStatus = state.status.headingStatus == 0
? AppLocalizations.of(
context,
@@ -680,7 +678,7 @@ class _DeviceStatusModalState extends State {
}
Widget _buildChartContentView(DeviceStatusState state) {
- if (_isDataTimeout || state is DeviceStatusInitial) {
+ if (state is DeviceStatusInitial) {
return _noDataWidget();
}
diff --git a/lib/core/bluetooth/ble_manager.dart b/lib/core/bluetooth/ble_manager.dart
new file mode 100644
index 00000000..25553972
--- /dev/null
+++ b/lib/core/bluetooth/ble_manager.dart
@@ -0,0 +1,448 @@
+import 'dart:async';
+import 'dart:developer' as developer;
+import 'dart:typed_data';
+import 'package:flutter_blue_plus/flutter_blue_plus.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'bytes_util.dart';
+import 'protocol_parser.dart';
+
+class BleManager {
+ static final BleManager instance = BleManager._internal();
+ BleManager._internal();
+
+ BluetoothDevice? _connectedDevice;
+ BluetoothDevice? _connectingDevice;
+ BluetoothCharacteristic? _writeCharacteristic;
+ BluetoothCharacteristic? _readCharacteristic;
+
+ /// 有状态的协议解析器(支持 BLE 分片)
+ final ProtocolParser _parser = ProtocolParser();
+
+ final Map _scanResults = {};
+ final StreamController> _scanController =
+ StreamController.broadcast();
+ final StreamController _packetController =
+ StreamController.broadcast();
+ final StreamController _connectionController =
+ StreamController.broadcast();
+ final StreamController _connectingController =
+ StreamController.broadcast();
+
+ StreamSubscription>? _scanSubscription;
+ StreamSubscription>? _readSubscription;
+ StreamSubscription? _adapterStateSubscription;
+ StreamSubscription? _deviceConnectionSubscription;
+
+ /// 防止异步竞态:stopScan() 后 in-flight 的 startScan() 不应生效
+ int _scanGen = 0;
+
+ /// 协商后的 MTU 值,用于分片写入
+ int _negotiatedMtu = 23;
+
+ /// 已收到的数据包存储(跨页面持久化)
+ final List receivedPacketStore = [];
+ static const int _maxStoredPackets = 100;
+
+ void clearReceivedPackets() {
+ receivedPacketStore.clear();
+ }
+
+ bool get isConnected => _connectedDevice != null;
+ BluetoothDevice? get connectedDevice => _connectedDevice;
+ BluetoothDevice? get connectingDevice => _connectingDevice;
+ Stream> get scanResults => _scanController.stream;
+ Stream get packetStream => _packetController.stream;
+ Stream get adapterState =>
+ FlutterBluePlus.adapterState;
+
+ /// 连接状态变化流:连接成功时发出 device,断开时发出 null
+ Stream get connectionStream => _connectionController.stream;
+
+ /// 连接中状态流:开始连接时发出 device,连接完成/失败时发出 null
+ Stream get connectingStream => _connectingController.stream;
+
+ Future checkBluetooth() async {
+ final state = await FlutterBluePlus.adapterState.first;
+ return state == BluetoothAdapterState.on;
+ }
+
+ Future requestPermissions() async {
+ final connStatus = await Permission.bluetoothConnect.status;
+ developer.log(
+ '[BLE] bluetoothConnect status: $connStatus',
+ name: 'BleManager',
+ );
+ if (connStatus.isDenied) {
+ developer.log('[BLE] requesting bluetoothConnect...', name: 'BleManager');
+ await Permission.bluetoothConnect.request();
+ }
+
+ final scanStatus = await Permission.bluetoothScan.status;
+ developer.log(
+ '[BLE] bluetoothScan status: $scanStatus',
+ name: 'BleManager',
+ );
+ if (scanStatus.isDenied) {
+ developer.log('[BLE] requesting bluetoothScan...', name: 'BleManager');
+ await Permission.bluetoothScan.request();
+ }
+
+ final locStatus = await Permission.locationWhenInUse.status;
+ developer.log(
+ '[BLE] locationWhenInUse status: $locStatus',
+ name: 'BleManager',
+ );
+ if (locStatus.isDenied) {
+ developer.log(
+ '[BLE] requesting locationWhenInUse...',
+ name: 'BleManager',
+ );
+ await Permission.locationWhenInUse.request();
+ }
+
+ final allGranted =
+ await Permission.bluetoothConnect.isGranted &&
+ await Permission.bluetoothScan.isGranted;
+ developer.log(
+ '[BLE] all permissions granted: $allGranted',
+ name: 'BleManager',
+ );
+ return allGranted;
+ }
+
+ Future openBluetooth() async {
+ try {
+ await FlutterBluePlus.turnOn();
+ } catch (e) {
+ await _goToSystemSettings();
+ }
+ }
+
+ Future _goToSystemSettings() async {
+ await openAppSettings();
+ }
+
+ Future _openLocationSettings() async {
+ await openAppSettings();
+ }
+
+ Future _checkLocationService() async {
+ final locStatus = await Permission.location.serviceStatus;
+ developer.log(
+ '[BLE] location service status: $locStatus',
+ name: 'BleManager',
+ );
+ return locStatus == ServiceStatus.enabled;
+ }
+
+ Future startScan({bool continuous = true}) async {
+ final int myGen = ++_scanGen;
+ developer.log(
+ '[BLE] startScan called, gen=$myGen, continuous=$continuous',
+ name: 'BleManager',
+ );
+ // 先停止任何残留扫描,确保干净启动
+ try { await FlutterBluePlus.stopScan(); } catch (_) {}
+ _scanResults.clear();
+ _scanController.add([]);
+
+ final isOn = await checkBluetooth();
+ if (myGen != _scanGen) return;
+ developer.log('[BLE] Bluetooth adapter on: $isOn', name: 'BleManager');
+ if (!isOn) {
+ developer.log(
+ '[BLE] Bluetooth is OFF, aborting scan',
+ name: 'BleManager',
+ );
+ return;
+ }
+
+ final granted = await requestPermissions();
+ if (myGen != _scanGen) return;
+ developer.log('[BLE] Permissions granted: $granted', name: 'BleManager');
+ if (!granted) {
+ developer.log(
+ '[BLE] Permissions not granted, aborting scan',
+ name: 'BleManager',
+ );
+ return;
+ }
+
+ final locEnabled = await _checkLocationService();
+ if (myGen != _scanGen) return;
+ if (!locEnabled) {
+ developer.log(
+ '[BLE] Location service OFF, directing to settings',
+ name: 'BleManager',
+ );
+ await _openLocationSettings();
+ return;
+ }
+
+ _scanSubscription?.cancel();
+ _scanSubscription = FlutterBluePlus.scanResults.listen(
+ (results) {
+ for (final result in results) {
+ _scanResults[result.device.remoteId] = result;
+ }
+ developer.log(
+ '[BLE] 📡 扫描到 ${results.length} 个设备: ${results.map((r) => '${r.device.platformName.isNotEmpty ? r.device.platformName : r.device.advName.isNotEmpty ? r.device.advName : r.device.remoteId} (${r.rssi}dBm)').join(', ')}',
+ name: 'BleManager',
+ );
+ _scanController.add(_scanResults.values.toList());
+ },
+ onError: (e) {
+ developer.log('[BLE] scanResults error: $e', name: 'BleManager');
+ },
+ );
+
+ try {
+ if (continuous) {
+ developer.log('[BLE] Starting continuous scan...', name: 'BleManager');
+ await FlutterBluePlus.startScan(
+ timeout: const Duration(days: 1),
+ androidUsesFineLocation: true,
+ androidScanMode: AndroidScanMode.lowLatency,
+ androidLegacy: true,
+ );
+ } else {
+ developer.log('[BLE] Starting 10s scan...', name: 'BleManager');
+ await FlutterBluePlus.startScan(
+ timeout: const Duration(seconds: 10),
+ androidUsesFineLocation: true,
+ androidScanMode: AndroidScanMode.lowLatency,
+ androidLegacy: true,
+ );
+ }
+ developer.log('[BLE] startScan completed', name: 'BleManager');
+ } catch (e, stackTrace) {
+ developer.log(
+ '[BLE] startScan error: $e\n$stackTrace',
+ name: 'BleManager',
+ );
+ }
+ }
+
+ Future refreshScan() async {
+ await stopScan();
+ await Future.delayed(const Duration(milliseconds: 200));
+ await startScan(continuous: true);
+ }
+
+ Future stopScan() async {
+ _scanGen++;
+ developer.log('[BLE] stopScan, gen=$_scanGen', name: 'BleManager');
+ _scanSubscription?.cancel();
+ await FlutterBluePlus.stopScan();
+ }
+
+ /// 连接设备,返回 null 表示成功,返回错误信息字符串表示失败
+ Future connect(BluetoothDevice device) async {
+ // 标记正在连接,通知所有监听者
+ _connectingDevice = device;
+ _connectingController.add(device);
+ try {
+ await device
+ .connect(license: License.nonprofit, mtu: 512)
+ .timeout(const Duration(seconds: 15));
+ _connectedDevice = device;
+ // 连接成功,立即停止扫描(避免扫描射频干扰导致连接断开)
+ await stopScan();
+ // 清除连接中状态
+ _connectingDevice = null;
+ _connectingController.add(null);
+ // 监听设备连接状态,任何一方断开都能感知
+ _deviceConnectionSubscription?.cancel();
+ _deviceConnectionSubscription = device.connectionState.listen((state) {
+ developer.log(
+ '[BLE] device connectionState: $state',
+ name: 'BleManager',
+ );
+ if (state == BluetoothConnectionState.disconnected) {
+ _onDeviceDisconnected();
+ }
+ });
+ _connectionController.add(device);
+ await _discoverServices(device);
+ // 确认协商后的 MTU
+ try {
+ final mtu = await device.requestMtu(512);
+ _negotiatedMtu = mtu;
+ developer.log('[BLE] MTU: $mtu', name: 'BleManager');
+ } catch (e) {
+ developer.log('[BLE] requestMtu failed: $e', name: 'BleManager');
+ }
+ return null; // 成功
+ } catch (e) {
+ // 连接失败,清除连接中状态
+ _connectingDevice = null;
+ _connectingController.add(null);
+ // 返回具体的错误原因
+ if (e is TimeoutException) {
+ return '连接超时(15秒),请确认设备在附近且已开启';
+ }
+ return '连接失败: $e';
+ }
+ }
+
+ void _onDeviceDisconnected() {
+ _connectedDevice = null;
+ _connectingDevice = null;
+ _writeCharacteristic = null;
+ _readCharacteristic = null;
+ _negotiatedMtu = 23;
+ _readSubscription?.cancel();
+ _deviceConnectionSubscription?.cancel();
+ _connectionController.add(null);
+ _connectingController.add(null);
+ // 清空协议解析器缓冲区
+ _parser.clear();
+ }
+
+ Future disconnect() async {
+ final device = _connectedDevice;
+ if (device == null) return;
+ developer.log('[BLE] 发起断开连接...', name: 'BleManager');
+ await device.disconnect();
+ // 等待连接状态流确认已断开,设置超时防止无限等待
+ try {
+ await device.connectionState
+ .firstWhere((s) => s == BluetoothConnectionState.disconnected)
+ .timeout(const Duration(seconds: 3));
+ developer.log('[BLE] 连接已确认断开', name: 'BleManager');
+ } catch (_) {
+ developer.log('[BLE] 等待断开超时,强制清理', name: 'BleManager');
+ }
+ _onDeviceDisconnected();
+ }
+
+ Future _discoverServices(BluetoothDevice device) async {
+ final services = await device.discoverServices();
+ for (final service in services) {
+ for (final characteristic in service.characteristics) {
+ if (characteristic.properties.write) {
+ _writeCharacteristic = characteristic;
+ }
+ if (characteristic.properties.notify) {
+ _readCharacteristic = characteristic;
+ await characteristic.setNotifyValue(true);
+ _readSubscription?.cancel();
+ _readSubscription = characteristic.lastValueStream.listen((value) {
+ _onDataReceived(value);
+ });
+ }
+ }
+ }
+ }
+
+ void _onDataReceived(List data) {
+ developer.log(
+ '[BLE] 📩 收: ${data.length}B ${_bytesToHex(data)}',
+ name: 'BleManager',
+ );
+ _parser.append(data);
+ final packets = _parser.parse();
+ developer.log(
+ '[BLE] 📦 解析 ${packets.length}包 (buf ${_parser.bufferLength}B)',
+ name: 'BleManager',
+ );
+ if (packets.isEmpty && data.isNotEmpty) {
+ developer.log(
+ '[BLE] ⚠️ 非标准帧 raw(${data.length}B)',
+ name: 'BleManager',
+ );
+ final packet = BlePacket(
+ command: 0x00,
+ payload: Uint8List.fromList(data),
+ );
+ _storePacket(packet);
+ _packetController.add(packet);
+ return;
+ }
+ for (final packet in packets) {
+ developer.log(
+ '[BLE] cmd=0x${packet.command.toRadixString(16).toUpperCase().padLeft(2, '0')} '
+ 'payload(${packet.payload.length}B): ${_bytesToHex(packet.payload)}',
+ name: 'BleManager',
+ );
+ _storePacket(packet);
+ _packetController.add(packet);
+ }
+ }
+
+ void _storePacket(BlePacket packet) {
+ receivedPacketStore.insert(0, packet);
+ if (receivedPacketStore.length > _maxStoredPackets) {
+ receivedPacketStore.removeLast();
+ }
+ }
+
+ String _bytesToHex(List bytes) {
+ if (bytes.isEmpty) return '';
+ return bytes
+ .map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0'))
+ .join(' ');
+ }
+
+ Future sendCommand(int command, List payload) async {
+ final char = _writeCharacteristic;
+ if (char == null) {
+ developer.log('[BLE] ❌ 写特征为空,无法发送', name: 'BleManager');
+ return;
+ }
+ final frame = ProtocolParser.pack(command, payload);
+ final crc = frame.length >= 5
+ ? 'CRC16=0x${frame[frame.length - 4].toRadixString(16).padLeft(2, '0')}${frame[frame.length - 3].toRadixString(16).padLeft(2, '0')}'
+ : '';
+ developer.log(
+ '[BLE] 📤 发送: 0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')}, '
+ '帧(${frame.length}B) $crc\n'
+ ' ${_bytesToHex(frame)}',
+ name: 'BleManager',
+ );
+ // 分片写入:每次最多写 (MTU - 3) 字节,全部走 withResponse 确保可靠性
+ final maxWriteLen = _negotiatedMtu - 3;
+ final totalChunks = (frame.length + maxWriteLen - 1) ~/ maxWriteLen;
+ if (totalChunks > 1) {
+ developer.log(
+ '[BLE] 🔀 分${totalChunks}片写入 (MTU=$_negotiatedMtu, 每片≤${maxWriteLen}B)',
+ name: 'BleManager',
+ );
+ }
+ for (int offset = 0; offset < frame.length; offset += maxWriteLen) {
+ final end = (offset + maxWriteLen <= frame.length)
+ ? offset + maxWriteLen
+ : frame.length;
+ final chunk = frame.sublist(offset, end);
+ if (totalChunks > 1) {
+ final chunkIdx = offset ~/ maxWriteLen + 1;
+ developer.log(
+ '[BLE] 片$chunkIdx/$totalChunks: offset=$offset len=${chunk.length}B ${_bytesToHex(chunk)}',
+ name: 'BleManager',
+ );
+ }
+ await char.write(chunk, withoutResponse: false);
+ }
+ developer.log('[BLE] ✅ 写入完成', name: 'BleManager');
+ }
+
+ Future sendRawBytes(List bytes) async {
+ if (_writeCharacteristic == null) return;
+ await _writeCharacteristic!.write(bytes, withoutResponse: false);
+ }
+
+ Future sendHeartbeat() async {
+ await sendCommand(0xFF, []);
+ }
+
+ void dispose() {
+ _scanSubscription?.cancel();
+ _readSubscription?.cancel();
+ _adapterStateSubscription?.cancel();
+ _deviceConnectionSubscription?.cancel();
+ _scanController.close();
+ _packetController.close();
+ _connectionController.close();
+ _connectingController.close();
+ }
+}
diff --git a/lib/core/bluetooth/ble_protocol_decoder.dart b/lib/core/bluetooth/ble_protocol_decoder.dart
new file mode 100644
index 00000000..9ccfd4fb
--- /dev/null
+++ b/lib/core/bluetooth/ble_protocol_decoder.dart
@@ -0,0 +1,722 @@
+import 'dart:convert';
+import 'dart:developer' as developer;
+import 'dart:typed_data';
+import '../protocol/machine_protocol_constants.dart';
+import 'mc700_device_config.dart';
+
+/// BLE 协议解析出的单个字段
+class BleField {
+ final String label;
+ final String value;
+ const BleField({required this.label, required this.value});
+}
+
+/// BLE 协议解析结果
+class BleDecodedResult {
+ final List fields;
+ final String rawHex;
+
+ /// 0x05 读配置时携带的配置实体(可修改后回写)
+ final Mc700DeviceConfig? configEntity;
+ const BleDecodedResult({
+ required this.fields,
+ required this.rawHex,
+ this.configEntity,
+ });
+ bool get isEmpty => fields.isEmpty;
+}
+
+/// BLE 协议解析器
+/// 与 TCP net_message_dispatcher 使用相同的协议格式:AB AA cmd [payload] ... AA AB
+/// payload 通常为 UTF-8 逗号分隔文本,部分指令为二进制
+class BleProtocolDecoder {
+ /// 入口:按命令类型分发解析
+ static BleDecodedResult decode(int command, Uint8List payload) {
+ final hex = _bytesToHex(payload);
+ try {
+ switch (command) {
+ case MachineProtocolConstants.cmdStatusInfo:
+ return _decodeStatusInfo(payload, hex);
+ case MachineProtocolConstants.cmdRemoteControl:
+ return _decodeRemoteControl(payload, hex);
+ case MachineProtocolConstants.cmdGetId:
+ return _decodeGetId(payload, hex);
+ case MachineProtocolConstants.cmdGetAuth:
+ return _decodeGetAuth(payload, hex);
+ case MachineProtocolConstants.cmdReadConfig:
+ return _decodeReadConfig(payload, hex);
+ case MachineProtocolConstants.cmdWriteConfig:
+ return _decodeWriteConfig(payload, hex);
+ case MachineProtocolConstants.cmdHeartbeat:
+ return _decodeHeartbeat(payload, hex);
+ case MachineProtocolConstants.cmdPathPlanning:
+ return _decodePathPlanning(payload, hex);
+ case MachineProtocolConstants.cmdObstacleAvoid:
+ return _decodeObstacleAvoid(payload, hex);
+ default:
+ return _decodeGeneric(command, payload, hex);
+ }
+ } catch (e) {
+ return BleDecodedResult(
+ fields: [const BleField(label: '解析错误', value: '异常')],
+ rawHex: hex,
+ );
+ }
+ }
+
+ // ─── 0x02 状态信息(与 TCP RunningStatusEntity.fromFields 一致,24字段逗号分隔) ───
+ static BleDecodedResult _decodeStatusInfo(Uint8List data, String hex) {
+ final fields = [];
+
+ try {
+ final text = utf8.decode(data);
+ if (text.contains(',')) {
+ final parts = text.split(',');
+ return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
+ }
+ } catch (e) {
+ try {
+ final text = latin1.decode(data);
+ if (text.contains(',')) {
+ final parts = text.split(',');
+ return BleDecodedResult(
+ fields: _parseStatusFields(parts),
+ rawHex: hex,
+ );
+ }
+ } catch (_) {}
+ }
+
+ if (data.isNotEmpty) {
+ final status = data[0];
+ fields.add(
+ BleField(
+ label: '状态码',
+ value: '0x${status.toRadixString(16).toUpperCase().padLeft(2, '0')}',
+ ),
+ );
+ }
+ if (data.length >= 2) {
+ final mode = data[1];
+ const modes = {0x00: '待机', 0x01: '遥控', 0x02: '自动', 0x03: '急停'};
+ fields.add(
+ BleField(
+ label: '控制模式',
+ value: modes[mode] ?? '未知(0x${mode.toRadixString(16)})',
+ ),
+ );
+ }
+ if (data.length >= 3)
+ fields.add(BleField(label: '电量', value: '${data[2]}%'));
+ if (data.length >= 4) {
+ final fault = data[3];
+ fields.add(
+ BleField(
+ label: '故障状态',
+ value: fault == 0
+ ? '无故障'
+ : '故障码: 0x${fault.toRadixString(16).toUpperCase().padLeft(2, '0')}',
+ ),
+ );
+ }
+ if (data.length >= 6) {
+ final speed = (data[5] << 8) | data[4];
+ fields.add(BleField(label: '速度', value: '$speed'));
+ }
+ if (data.length > 8) {
+ try {
+ final text = String.fromCharCodes(data.sublist(8));
+ if (text.isNotEmpty && !text.contains('\x00')) {
+ fields.add(BleField(label: '附加', value: text));
+ }
+ } catch (_) {}
+ }
+
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ /// 解析逗号分隔的 24 字段状态数据(与 RunningStatusEntity.fromFields 完全一致)
+ static List _parseStatusFields(List parts) {
+ while (parts.length < 24) {
+ parts.add('');
+ }
+
+ String controlModeText(String v) {
+ return switch (v) {
+ '0' => '待机',
+ '1' => '遥控',
+ '2' => '自动',
+ '3' => '急停',
+ _ => v.isNotEmpty ? v : '--',
+ };
+ }
+
+ String qualText(String v) {
+ final q = int.tryParse(v) ?? -1;
+ return switch (q) {
+ 0 => '无效',
+ 1 => '单点定位',
+ 2 => '差分定位',
+ 4 => '固定解',
+ 5 => '浮点解',
+ _ => v.isNotEmpty ? v : '--',
+ };
+ }
+
+ String headingText(String v) {
+ final h = int.tryParse(v) ?? -1;
+ return switch (h) {
+ 0 => '未初始化',
+ 1 => '已初始化',
+ _ => v.isNotEmpty ? v : '--',
+ };
+ }
+
+ String obstacleText(String v) {
+ final o = int.tryParse(v) ?? -1;
+ return switch (o) {
+ 0 => '无障碍',
+ 1 => '有障碍',
+ _ => v.isNotEmpty ? v : '--',
+ };
+ }
+
+ return [
+ BleField(label: '电压', value: '${_tryParseDouble(parts[0])} V'),
+ BleField(label: '左目标速度', value: _tryParseDouble(parts[1])),
+ BleField(label: '右目标速度', value: _tryParseDouble(parts[2])),
+ BleField(label: '左实测速度', value: _tryParseDouble(parts[3])),
+ BleField(label: '右实测速度', value: _tryParseDouble(parts[4])),
+ BleField(label: '左电流', value: '${_tryParseDouble(parts[5])} A'),
+ BleField(label: '右电流', value: '${_tryParseDouble(parts[6])} A'),
+ BleField(label: '左电机温度', value: '${_tryParseDouble(parts[7])} ℃'),
+ BleField(label: '右电机温度', value: '${_tryParseDouble(parts[8])} ℃'),
+ BleField(label: '芯片温度', value: '${_tryParseDouble(parts[9])} ℃'),
+ BleField(label: '偏航角', value: '${_tryParseDouble(parts[10])} °'),
+ BleField(label: '俯仰角', value: '${_tryParseDouble(parts[11])} °'),
+ BleField(label: '翻滚角', value: '${_tryParseDouble(parts[12])} °'),
+ BleField(label: '卫星数量', value: parts[13].isNotEmpty ? parts[13] : '--'),
+ BleField(label: '定位质量', value: qualText(parts[14])),
+ BleField(label: '航向状态', value: headingText(parts[15])),
+ BleField(label: '纬度', value: parts[16].isNotEmpty ? parts[16] : '--'),
+ BleField(label: '经度', value: parts[17].isNotEmpty ? parts[17] : '--'),
+ BleField(label: '时间戳', value: parts[18].isNotEmpty ? parts[18] : '--'),
+ BleField(
+ label: '割刀速度',
+ value: parts[19].isNotEmpty ? '${parts[19]} rpm' : '--',
+ ),
+ BleField(label: '控制模式', value: controlModeText(parts[20])),
+ BleField(
+ label: '电量',
+ value: parts[21].isNotEmpty ? '${parts[21]}%' : '--',
+ ),
+ BleField(label: '工作面积', value: parts[22].isNotEmpty ? parts[22] : '--'),
+ BleField(label: '障碍物', value: obstacleText(parts[23])),
+ ];
+ }
+
+ static String _tryParseDouble(String s) {
+ if (s.isEmpty) return '--';
+ final v = double.tryParse(s);
+ if (v == null) return s;
+ if (v == v.roundToDouble() && v.abs() < 10000) return v.toInt().toString();
+ return v.toStringAsFixed(2);
+ }
+
+ // ─── 0x00 远程遥控 ───
+ static BleDecodedResult _decodeRemoteControl(Uint8List data, String hex) {
+ final fields = [];
+ try {
+ final text = utf8.decode(data, allowMalformed: true);
+ if (text.contains(',')) {
+ final parts = text.split(',');
+ while (parts.length < 5) parts.add('');
+ fields.add(
+ BleField(label: '左右速度', value: parts[0].isNotEmpty ? parts[0] : '--'),
+ );
+ fields.add(
+ BleField(label: '前后速度', value: parts[1].isNotEmpty ? parts[1] : '--'),
+ );
+ final modeStr = switch (parts[2]) {
+ '0' => '停止',
+ '1' => '遥控',
+ '2' => '自动',
+ _ => parts[2].isNotEmpty ? parts[2] : '--',
+ };
+ fields.add(BleField(label: '控制模式', value: modeStr));
+ if (parts.length > 3 && parts[3].isNotEmpty)
+ fields.add(BleField(label: '参数4', value: parts[3]));
+ if (parts.length > 4 && parts[4].isNotEmpty)
+ fields.add(BleField(label: '参数5', value: parts[4]));
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+ } catch (_) {}
+
+ if (data.length >= 5) {
+ fields.add(BleField(label: '左右速度', value: '${(data[1] << 8) | data[0]}'));
+ fields.add(BleField(label: '前后速度', value: '${(data[3] << 8) | data[2]}'));
+ const modes = {0: '停止', 1: '遥控', 2: '自动'};
+ fields.add(
+ BleField(label: '控制模式', value: modes[data[4]] ?? '未知(${data[4]})'),
+ );
+ }
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ // ─── 0x03 查询ID ───
+ static BleDecodedResult _decodeGetId(Uint8List data, String hex) {
+ final fields = [];
+ try {
+ final text = utf8.decode(data, allowMalformed: true);
+ if (text.replaceAll('\x00', '').trim().isNotEmpty) {
+ fields.add(
+ BleField(label: '设备ID', value: text.replaceAll('\x00', '').trim()),
+ );
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+ } catch (_) {}
+ try {
+ final ascii = String.fromCharCodes(data);
+ if (RegExp(r'^[\x20-\x7E]+$').hasMatch(ascii)) {
+ fields.add(BleField(label: '设备ID', value: ascii));
+ } else {
+ fields.add(BleField(label: '设备ID(Hex)', value: hex));
+ }
+ } catch (_) {
+ fields.add(BleField(label: '设备ID(Hex)', value: hex));
+ }
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ // ─── 0x04 获取授权 ───
+ static BleDecodedResult _decodeGetAuth(Uint8List data, String hex) {
+ final fields = [];
+ try {
+ final text = utf8.decode(data, allowMalformed: true);
+ final trimmed = text.replaceAll('\x00', '').trim();
+ if (trimmed == '1' || trimmed.toLowerCase() == 'true') {
+ fields.add(const BleField(label: '授权状态', value: '已授权'));
+ } else if (trimmed == '0' || trimmed.toLowerCase() == 'false') {
+ fields.add(const BleField(label: '授权状态', value: '未授权'));
+ } else if (trimmed.isNotEmpty) {
+ fields.add(BleField(label: '授权状态', value: trimmed));
+ }
+ if (fields.isNotEmpty)
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ } catch (_) {}
+
+ if (data.isNotEmpty) {
+ if (data[0] == 0x01) {
+ fields.add(const BleField(label: '授权状态', value: '已授权'));
+ } else if (data[0] == 0x00) {
+ fields.add(const BleField(label: '授权状态', value: '未授权'));
+ } else {
+ fields.add(
+ BleField(label: '授权状态(Hex)', value: '0x${data[0].toRadixString(16)}'),
+ );
+ }
+ }
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ // ─── 0x05 读配置(按嵌入式协议规范精确解析,171字节) ───
+ static BleDecodedResult _decodeReadConfig(Uint8List data, String hex) {
+ final fields = [];
+ if (data.isEmpty) {
+ fields.add(const BleField(label: '状态', value: '空数据'));
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ final bd = ByteData.sublistView(data);
+ final len = data.length;
+
+ // 1. UID固化标志 @payload[0] (帧字节3)
+ if (len >= 1) {
+ fields.add(
+ BleField(
+ label: 'UID固化标志',
+ value: data[0] == 1 ? '已固化' : '未固化(${data[0]})',
+ ),
+ );
+ }
+
+ // 2. 芯片UID @payload[1..45] (帧字节4-48, 45字节)
+ if (len >= 46) {
+ fields.add(
+ BleField(label: '芯片UID', value: _readNullTermString(data, 1, 45)),
+ );
+ }
+
+ // 3. 遥控器通道配置 @payload[46..64] (帧字节49-67, 19字节)
+ if (len >= 65) {
+ fields.add(
+ BleField(label: '遥控器通道配置', value: _bytesToHex(data.sublist(46, 65))),
+ );
+ }
+
+ // 4. 字节68位域 @payload[65] (帧字节68)
+ if (len >= 66) {
+ final b = data[65];
+ fields.add(
+ BleField(
+ label: '割刀电机模式',
+ value: (b & 0x03) == 0 ? '纯电' : '油电(${b & 0x03})',
+ ),
+ );
+ fields.add(
+ BleField(
+ label: '行走电机模式',
+ value: ((b >> 2) & 0x03) == 0 ? '轮式' : '履带(${(b >> 2) & 0x03})',
+ ),
+ );
+ fields.add(
+ BleField(label: '左轮电机极性', value: (b & 0x10) == 0 ? '高极性' : '低极性(反取)'),
+ );
+ fields.add(
+ BleField(label: '右轮电机极性', value: (b & 0x20) == 0 ? '高极性' : '低极性(反取)'),
+ );
+ fields.add(
+ BleField(label: '左右轮通道交换', value: (b & 0x40) == 0 ? '不交换' : '通道互换'),
+ );
+ fields.add(
+ BleField(label: '联网目标', value: (b & 0x80) == 0 ? 'WiFi' : '4G'),
+ );
+ }
+
+ // 5. 前进速度限制 @payload[66..67] (帧字节69-70, uint16 LE)
+ if (len >= 68) {
+ fields.add(
+ BleField(label: '前进速度限制', value: '${bd.getUint16(66, Endian.little)}'),
+ );
+ }
+
+ // 6. 转向速度限制 @payload[68..69] (帧字节71-72, uint16 LE)
+ if (len >= 70) {
+ fields.add(
+ BleField(label: '转向速度限制', value: '${bd.getUint16(68, Endian.little)}'),
+ );
+ }
+
+ // 7. 字节73位域 @payload[70] (帧字节73)
+ if (len >= 71) {
+ final b = data[70];
+ fields.add(
+ BleField(label: '割刀通道极性', value: (b & 0x01) == 0 ? '高极性' : '低极性(反取)'),
+ );
+ fields.add(
+ BleField(label: '风门通道极性', value: (b & 0x02) == 0 ? '高极性' : '低极性(反取)'),
+ );
+ fields.add(
+ BleField(label: '油门通道极性', value: (b & 0x04) == 0 ? '高极性' : '低极性(反取)'),
+ );
+ fields.add(BleField(label: '底盘升降保护时间', value: '${(b >> 3) & 0x1F} 秒'));
+ fields.add(
+ BleField(label: 'RTK配置', value: (b & 0x80) == 0 ? '单天线' : '双天线'),
+ );
+ }
+
+ // 8. 字节74位域 @payload[71] (帧字节74)
+ if (len >= 72) {
+ final b = data[71];
+ fields.add(BleField(label: '割刀通道配置', value: '${b & 0x0F}'));
+ fields.add(BleField(label: '风门通道配置', value: '${(b >> 4) & 0x0F}'));
+ }
+
+ // 9. 字节75位域 @payload[72] (帧字节75)
+ if (len >= 73) {
+ final b = data[72];
+ fields.add(BleField(label: '油门通道配置', value: '${b & 0x0F}'));
+ fields.add(
+ BleField(
+ label: '遥控器类型',
+ value: (b >> 4) & 0x07 == 0 ? '飞控' : '自定义遥控器(${(b >> 4) & 0x07})',
+ ),
+ );
+ fields.add(
+ BleField(label: '是否搭载继电器板', value: (b & 0x80) == 0 ? '未搭载' : '搭载'),
+ );
+ }
+
+ // 10. 字节76位域 @payload[73] (帧字节76)
+ if (len >= 74) {
+ final b = data[73];
+ fields.add(BleField(label: '底盘升降通道配置', value: '${b & 0x0F}'));
+ fields.add(BleField(label: '底盘通道配置', value: '${(b >> 4) & 0x0F}'));
+ }
+
+ // 11. 字节77位域 @payload[74] (帧字节77)
+ if (len >= 75) {
+ final b = data[74];
+ fields.add(BleField(label: '机械臂通道配置', value: '${b & 0x0F}'));
+ fields.add(BleField(label: '燃油泵通道配置', value: '${(b >> 4) & 0x0F}'));
+ }
+
+ // 12. WiFi名称 @payload[75..94] (帧字节78-97, 20字节)
+ if (len >= 95) {
+ fields.add(
+ BleField(label: 'WiFi名称', value: _readNullTermString(data, 75, 20)),
+ );
+ }
+
+ // 13. WiFi密码 @payload[95..114] (帧字节98-117, 20字节)
+ if (len >= 115) {
+ fields.add(
+ BleField(label: 'WiFi密码', value: _readNullTermString(data, 95, 20)),
+ );
+ }
+
+ // 14. 字节118位域 @payload[115] (帧字节118)
+ if (len >= 116) {
+ final b = data[115];
+ fields.add(
+ BleField(
+ label: '电池类型',
+ value: (b & 0x03) == 0 ? '铅酸' : '锂电(${b & 0x03})',
+ ),
+ );
+ fields.add(
+ BleField(
+ label: '行走驱动配置',
+ value: switch ((b >> 2) & 0x03) {
+ 0 => '风德控',
+ 1 => '山东神澜BLDC',
+ 2 => '山东神澜FOC',
+ _ => '未知(${(b >> 2) & 0x03})',
+ },
+ ),
+ );
+ }
+
+ // 15. 转速比 @payload[116..119] (帧字节119-122, float LE)
+ if (len >= 120) {
+ final v = bd.getFloat32(116, Endian.little);
+ fields.add(BleField(label: '转速比', value: v.toStringAsFixed(2)));
+ }
+
+ // 16. 机器人长度 @payload[120..123] (帧字节123-126, float LE)
+ if (len >= 124) {
+ final v = bd.getFloat32(120, Endian.little);
+ fields.add(BleField(label: '机器人长度', value: v.toStringAsFixed(2)));
+ }
+
+ // 17. 机器人宽度 @payload[124..127] (帧字节127-130, float LE)
+ if (len >= 128) {
+ final v = bd.getFloat32(124, Endian.little);
+ fields.add(BleField(label: '机器人宽度', value: v.toStringAsFixed(2)));
+ }
+
+ // 18. 机器人高度 @payload[128..131] (帧字节131-134, float LE)
+ if (len >= 132) {
+ final v = bd.getFloat32(128, Endian.little);
+ fields.add(BleField(label: '机器人高度', value: v.toStringAsFixed(2)));
+ }
+
+ // 19. 割刀宽度 @payload[132..135] (帧字节135-138, float LE)
+ if (len >= 136) {
+ final v = bd.getFloat32(132, Endian.little);
+ fields.add(BleField(label: '割刀宽度', value: v.toStringAsFixed(2)));
+ }
+
+ // 20. 轮胎尺寸 @payload[136..139] (帧字节139-142, float LE)
+ if (len >= 140) {
+ final v = bd.getFloat32(136, Endian.little);
+ fields.add(BleField(label: '轮胎尺寸', value: v.toStringAsFixed(2)));
+ }
+
+ // 21. 左轮前进增益 @payload[140..143] (帧字节143-146, float LE)
+ if (len >= 144) {
+ final v = bd.getFloat32(140, Endian.little);
+ fields.add(BleField(label: '左轮前进增益', value: v.toStringAsFixed(2)));
+ }
+
+ // 22. 左轮后退增益 @payload[144..147] (帧字节147-150, float LE)
+ if (len >= 148) {
+ final v = bd.getFloat32(144, Endian.little);
+ fields.add(BleField(label: '左轮后退增益', value: v.toStringAsFixed(2)));
+ }
+
+ // 23. 右轮前进增益 @payload[148..151] (帧字节151-154, float LE)
+ if (len >= 152) {
+ final v = bd.getFloat32(148, Endian.little);
+ fields.add(BleField(label: '右轮前进增益', value: v.toStringAsFixed(2)));
+ }
+
+ // 24. 右轮后退增益 @payload[152..155] (帧字节155-158, float LE)
+ if (len >= 156) {
+ final v = bd.getFloat32(152, Endian.little);
+ fields.add(BleField(label: '右轮后退增益', value: v.toStringAsFixed(2)));
+ }
+
+ // 25. 固件版本 @payload[156..170] (帧字节159-173, 15字节)
+ if (len >= 171) {
+ fields.add(
+ BleField(label: '固件版本', value: _readNullTermString(data, 156, 15)),
+ );
+ }
+
+ // 构建配置实体(用于后续编辑和回写)
+ Mc700DeviceConfig? configEntity;
+ if (data.length >= 171) {
+ try {
+ configEntity = Mc700DeviceConfig.fromBytes(data);
+ } catch (e) {
+ developer.log('[BLE] 配置实体解析失败: $e', name: 'BleProtocolDecoder');
+ }
+ }
+
+ return BleDecodedResult(
+ fields: fields,
+ rawHex: hex,
+ configEntity: configEntity,
+ );
+ }
+
+ /// 读取 null 终止字符串(最多读取 [maxLen] 字节)
+ static String _readNullTermString(Uint8List data, int offset, int maxLen) {
+ final buffer = StringBuffer();
+ for (int i = 0; i < maxLen && offset + i < data.length; i++) {
+ final b = data[offset + i];
+ if (b == 0x00) break;
+ if (b >= 0x20 && b <= 0x7E) {
+ buffer.writeCharCode(b);
+ } else {
+ buffer.writeCharCode(0x2E);
+ }
+ }
+ return buffer.toString();
+ }
+
+ // ─── 0x06 写配置 ───
+ static BleDecodedResult _decodeWriteConfig(Uint8List data, String hex) {
+ final fields = [];
+ if (data.isNotEmpty) {
+ final ok = data[0];
+ fields.add(
+ BleField(
+ label: '写入结果',
+ value: ok == 0x01
+ ? '成功'
+ : ok == 0x00
+ ? '失败'
+ : '0x${ok.toRadixString(16)}',
+ ),
+ );
+ }
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ // ─── 0xFF 心跳包 ───
+ static BleDecodedResult _decodeHeartbeat(Uint8List data, String hex) {
+ final fields = [];
+ if (data.isEmpty) {
+ fields.add(const BleField(label: '心跳', value: '正常'));
+ } else if (data.length >= 2) {
+ final interval = (data[1] << 8) | data[0];
+ fields.add(BleField(label: '心跳间隔', value: '${interval}ms'));
+ } else {
+ fields.add(BleField(label: '心跳数据', value: hex));
+ }
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ // ─── 0x01 路径规划 ───
+ static BleDecodedResult _decodePathPlanning(Uint8List data, String hex) {
+ final fields = [];
+ try {
+ final text = utf8.decode(data, allowMalformed: true);
+ if (text.contains(',')) {
+ final parts = text.split(',');
+ if (parts.length >= 2) {
+ fields.add(BleField(label: '点编号', value: parts[0]));
+ fields.add(
+ BleField(
+ label: '状态',
+ value: switch (parts[1]) {
+ '1' => '已到达',
+ '2' => '收到指令',
+ _ => parts[1],
+ },
+ ),
+ );
+ }
+ if (parts.length >= 4) {
+ fields.add(BleField(label: '纬度', value: parts[2]));
+ fields.add(BleField(label: '经度', value: parts[3]));
+ }
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+ } catch (_) {}
+ if (data.length >= 3) {
+ fields.add(BleField(label: '点编号', value: '${data[0]}'));
+ final status = data.length > 1 ? data[1] : -1;
+ fields.add(
+ BleField(
+ label: '状态',
+ value: status == 0x01
+ ? '已到达'
+ : status == 0x02
+ ? '收到指令'
+ : '0x${status.toRadixString(16)}',
+ ),
+ );
+ }
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ // ─── 0x07 避障 ───
+ static BleDecodedResult _decodeObstacleAvoid(Uint8List data, String hex) {
+ final fields = [];
+ if (data.isNotEmpty) {
+ final flag = data[0];
+ fields.add(
+ BleField(
+ label: '避障状态',
+ value: flag == 0x01
+ ? '触发'
+ : flag == 0x00
+ ? '正常'
+ : '0x${flag.toRadixString(16)}',
+ ),
+ );
+ }
+ if (data.length >= 3) {
+ fields.add(BleField(label: '距离', value: '${(data[2] << 8) | data[1]}mm'));
+ }
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ // ─── 通用/未知指令 ───
+ static BleDecodedResult _decodeGeneric(
+ int command,
+ Uint8List data,
+ String hex,
+ ) {
+ final fields = [];
+ try {
+ final text = utf8.decode(data, allowMalformed: true);
+ final trimmed = text.replaceAll('\x00', '').trim();
+ if (trimmed.isNotEmpty) {
+ fields.add(
+ BleField(
+ label: '文本内容',
+ value: trimmed.length > 50
+ ? '${trimmed.substring(0, 50)}...'
+ : trimmed,
+ ),
+ );
+ }
+ } catch (_) {}
+ if (fields.isEmpty) {
+ fields.add(BleField(label: '数据长度', value: '${data.length}B'));
+ }
+ return BleDecodedResult(fields: fields, rawHex: hex);
+ }
+
+ // ─── 工具方法 ───
+ static String _bytesToHex(List bytes) {
+ if (bytes.isEmpty) return '';
+ return bytes
+ .map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0'))
+ .join(' ');
+ }
+}
diff --git a/lib/core/bluetooth/bytes_util.dart b/lib/core/bluetooth/bytes_util.dart
new file mode 100644
index 00000000..510450fd
--- /dev/null
+++ b/lib/core/bluetooth/bytes_util.dart
@@ -0,0 +1,38 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
+class BytesUtil {
+ static String bytesToHex(List bytes) {
+ return bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join();
+ }
+
+ static Uint8List hexToBytes(String hex) {
+ hex = hex.replaceAll(' ', '').replaceAll('-', '');
+ if (hex.length % 2 != 0) hex = '0' + hex;
+ final bytes = Uint8List(hex.length ~/ 2);
+ for (int i = 0; i < hex.length ~/ 2; i++) {
+ bytes[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
+ }
+ return bytes;
+ }
+
+ static String bytesToString(List bytes) {
+ try {
+ return utf8.decode(bytes);
+ } catch (_) {
+ return String.fromCharCodes(bytes);
+ }
+ }
+
+ static Uint8List stringToBytes(String str) {
+ return Uint8List.fromList(utf8.encode(str));
+ }
+
+ static List toByteList(Uint8List data) {
+ return List.from(data);
+ }
+
+ static String bytesToHexSpaced(List bytes) {
+ return bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ');
+ }
+}
diff --git a/lib/core/bluetooth/mc700_device_config.dart b/lib/core/bluetooth/mc700_device_config.dart
new file mode 100644
index 00000000..97089415
--- /dev/null
+++ b/lib/core/bluetooth/mc700_device_config.dart
@@ -0,0 +1,402 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
+/// MC700设备配置实体类(与嵌入式协议规范一一对应)
+/// 所有偏移均为 payload 内偏移(ProtocolParser 已剥离帧头/命令/校验/帧尾)
+/// payload 共 171 字节,索引 0~170
+class Mc700DeviceConfig {
+ // ====== 字段定义(按嵌入式协议规范) ======
+
+ // --- 基础标识 ---
+ late int uidSolidifiedFlag; // payload[0] UID固化标志 (0=未固化, 1=已固化)
+ late String chipUid; // payload[1..45] 芯片UID (45字节)
+ late List remoteChannelConfig; // payload[46..64] 遥控器通道配置 (19字节)
+
+ // --- 字节68位域 (payload[65]) ---
+ late int knifeMotorMode; // bit0-1 割刀电机模式 (0=纯电, 1=油电)
+ late int walkMotorMode; // bit2-3 行走电机模式 (0=轮式, 1=履带)
+ late bool leftWheelPolarity; // bit4 左轮极性 (false=高, true=低)
+ late bool rightWheelPolarity; // bit5 右轮极性 (false=高, true=低)
+ late bool channelSwap; // bit6 左右轮通道交换
+ late bool use4g; // bit7 联网目标 (false=WiFi, true=4G)
+
+ // --- 速度限制 ---
+ late int forwardSpeedLimit; // payload[66..67] 前进速度限制 (uint16 LE)
+ late int turnSpeedLimit; // payload[68..69] 转向速度限制 (uint16 LE)
+
+ // --- 字节73位域 (payload[70]) ---
+ late bool knifeChannelPolarity; // bit0 割刀通道极性
+ late bool fanChannelPolarity; // bit1 风门通道极性
+ late bool throttleChannelPolarity; // bit2 油门通道极性
+ late int liftProtectTime; // bit3-6 底盘升降保护时间 (秒)
+ late bool dualRtk; // bit7 RTK配置 (false=单天线, true=双天线)
+
+ // --- 字节74位域 (payload[71]) ---
+ late int knifeChannelConfig; // bit0-3 割刀通道配置
+ late int fanChannelConfig; // bit4-7 风门通道配置
+
+ // --- 字节75位域 (payload[72]) ---
+ late int throttleChannelConfig; // bit0-3 油门通道配置
+ late int remoteType; // bit4-6 遥控器类型 (0=飞控, 1=自定义1)
+ late bool relayBoard; // bit7 是否搭载继电器板
+
+ // --- 字节76位域 (payload[73]) ---
+ late int chassisLiftChannel; // bit0-3 底盘升降通道配置
+ late int chassisChannel; // bit4-7 底盘通道配置
+
+ // --- 字节77位域 (payload[74]) ---
+ late int armChannel; // bit0-3 机械臂通道配置
+ late int fuelPumpChannel; // bit4-7 燃油泵通道配置
+
+ // --- WiFi ---
+ late String wifiName; // payload[75..94] WiFi名称 (20字节)
+ late String wifiPassword; // payload[95..114] WiFi密码 (20字节)
+
+ // --- 字节118位域 (payload[115]) ---
+ late int batteryType; // bit0-1 电池类型 (0=铅酸, 1=锂电)
+ late int walkDriveConfig; // bit2-3 行走驱动配置
+
+ // --- 尺寸参数 (float LE) ---
+ late double gearRatio; // payload[116..119] 转速比
+ late double robotLength; // payload[120..123] 机器人长度
+ late double robotWidth; // payload[124..127] 机器人宽度
+ late double robotHeight; // payload[128..131] 机器人高度
+ late double knifeWidth; // payload[132..135] 割刀宽度
+ late double tireSize; // payload[136..139] 轮胎尺寸
+
+ // --- 增益 (float LE) ---
+ late double leftForwardGain; // payload[140..143] 左轮前进增益
+ late double leftBackwardGain; // payload[144..147] 左轮后退增益
+ late double rightForwardGain; // payload[148..151] 右轮前进增益
+ late double rightBackwardGain; // payload[152..155] 右轮后退增益
+
+ // --- 固件版本 ---
+ late String firmwareVersion; // payload[156..170] 固件版本 (15字节)
+
+ Mc700DeviceConfig();
+
+ // ====== 从二进制数据解析(171字节 payload) ======
+ factory Mc700DeviceConfig.fromBytes(Uint8List data) {
+ if (data.length < 171) {
+ throw Exception('数据长度不足171字节,实际${data.length}字节');
+ }
+ final bd = ByteData.sublistView(data);
+ final obj = Mc700DeviceConfig();
+
+ // 1. UID固化标志 @0
+ obj.uidSolidifiedFlag = bd.getUint8(0);
+ // 2. 芯片UID @1..45 (45字节)
+ obj.chipUid = _readNullTerminatedString(data, 1, 45);
+ // 3. 遥控器通道配置 @46..64 (19字节)
+ obj.remoteChannelConfig = List.unmodifiable(data.sublist(46, 65));
+
+ // 4. 字节68位域 @65
+ final b68 = bd.getUint8(65);
+ obj.knifeMotorMode = b68 & 0x03;
+ obj.walkMotorMode = (b68 >> 2) & 0x03;
+ obj.leftWheelPolarity = (b68 & 0x10) != 0;
+ obj.rightWheelPolarity = (b68 & 0x20) != 0;
+ obj.channelSwap = (b68 & 0x40) != 0;
+ obj.use4g = (b68 & 0x80) != 0;
+
+ // 5. 前进速度限制 @66..67
+ obj.forwardSpeedLimit = bd.getUint16(66, Endian.little);
+ // 6. 转向速度限制 @68..69
+ obj.turnSpeedLimit = bd.getUint16(68, Endian.little);
+
+ // 7. 字节73位域 @70
+ final b73 = bd.getUint8(70);
+ obj.knifeChannelPolarity = (b73 & 0x01) != 0;
+ obj.fanChannelPolarity = (b73 & 0x02) != 0;
+ obj.throttleChannelPolarity = (b73 & 0x04) != 0;
+ obj.liftProtectTime = (b73 >> 3) & 0x1F;
+ obj.dualRtk = (b73 & 0x80) != 0;
+
+ // 8. 字节74位域 @71
+ final b74 = bd.getUint8(71);
+ obj.knifeChannelConfig = b74 & 0x0F;
+ obj.fanChannelConfig = (b74 >> 4) & 0x0F;
+
+ // 9. 字节75位域 @72
+ final b75 = bd.getUint8(72);
+ obj.throttleChannelConfig = b75 & 0x0F;
+ obj.remoteType = (b75 >> 4) & 0x07;
+ obj.relayBoard = (b75 & 0x80) != 0;
+
+ // 10. 字节76位域 @73
+ final b76 = bd.getUint8(73);
+ obj.chassisLiftChannel = b76 & 0x0F;
+ obj.chassisChannel = (b76 >> 4) & 0x0F;
+
+ // 11. 字节77位域 @74
+ final b77 = bd.getUint8(74);
+ obj.armChannel = b77 & 0x0F;
+ obj.fuelPumpChannel = (b77 >> 4) & 0x0F;
+
+ // 12. WiFi名称 @75..94 (20字节)
+ obj.wifiName = _readNullTerminatedString(data, 75, 20);
+ // 13. WiFi密码 @95..114 (20字节)
+ obj.wifiPassword = _readNullTerminatedString(data, 95, 20);
+
+ // 14. 字节118位域 @115
+ final b118 = bd.getUint8(115);
+ obj.batteryType = b118 & 0x03;
+ obj.walkDriveConfig = (b118 >> 2) & 0x03;
+
+ // 15. 转速比 @116..119
+ obj.gearRatio = bd.getFloat32(116, Endian.little);
+ // 16. 机器人长度 @120..123
+ obj.robotLength = bd.getFloat32(120, Endian.little);
+ // 17. 机器人宽度 @124..127
+ obj.robotWidth = bd.getFloat32(124, Endian.little);
+ // 18. 机器人高度 @128..131
+ obj.robotHeight = bd.getFloat32(128, Endian.little);
+ // 19. 割刀宽度 @132..135
+ obj.knifeWidth = bd.getFloat32(132, Endian.little);
+ // 20. 轮胎尺寸 @136..139
+ obj.tireSize = bd.getFloat32(136, Endian.little);
+
+ // 21. 左轮前进增益 @140..143
+ obj.leftForwardGain = bd.getFloat32(140, Endian.little);
+ // 22. 左轮后退增益 @144..147
+ obj.leftBackwardGain = bd.getFloat32(144, Endian.little);
+ // 23. 右轮前进增益 @148..151
+ obj.rightForwardGain = bd.getFloat32(148, Endian.little);
+ // 24. 右轮后退增益 @152..155
+ obj.rightBackwardGain = bd.getFloat32(152, Endian.little);
+
+ // 25. 固件版本 @156..170 (15字节)
+ obj.firmwareVersion = _readNullTerminatedString(data, 156, 15);
+
+ return obj;
+ }
+
+ // ====== 转换回二进制字节(用于写配置下发) ======
+ /// [originalPayload] 是上次读取的 171 字节 payload,用于保留未修改字段
+ Uint8List toBytes(Uint8List originalPayload) {
+ final config = Uint8List.fromList(originalPayload);
+ final bd = ByteData.sublistView(config);
+
+ // UID固化标志 @0(独立字段,不属于字节68)
+ bd.setUint8(0, uidSolidifiedFlag & 0x01);
+
+ // 字节68位域 @65
+ int b68 = 0;
+ b68 |= (knifeMotorMode & 0x03);
+ b68 |= (walkMotorMode & 0x03) << 2;
+ if (leftWheelPolarity) b68 |= 0x10;
+ if (rightWheelPolarity) b68 |= 0x20;
+ if (channelSwap) b68 |= 0x40;
+ if (use4g) b68 |= 0x80;
+ bd.setUint8(65, b68);
+
+ // 速度限制
+ bd.setUint16(66, forwardSpeedLimit, Endian.little);
+ bd.setUint16(68, turnSpeedLimit, Endian.little);
+
+ // 字节73位域 @70
+ int b73 = (liftProtectTime & 0x1F) << 3;
+ if (knifeChannelPolarity) b73 |= 0x01;
+ if (fanChannelPolarity) b73 |= 0x02;
+ if (throttleChannelPolarity) b73 |= 0x04;
+ if (dualRtk) b73 |= 0x80;
+ bd.setUint8(70, b73);
+
+ // 字节74位域 @71
+ int b74 = knifeChannelConfig & 0x0F;
+ b74 |= (fanChannelConfig & 0x0F) << 4;
+ bd.setUint8(71, b74);
+
+ // 字节75位域 @72
+ int b75 = throttleChannelConfig & 0x0F;
+ b75 |= (remoteType & 0x07) << 4;
+ if (relayBoard) b75 |= 0x80;
+ bd.setUint8(72, b75);
+
+ // 字节76位域 @73
+ int b76 = chassisLiftChannel & 0x0F;
+ b76 |= (chassisChannel & 0x0F) << 4;
+ bd.setUint8(73, b76);
+
+ // 字节77位域 @74
+ int b77 = armChannel & 0x0F;
+ b77 |= (fuelPumpChannel & 0x0F) << 4;
+ bd.setUint8(74, b77);
+
+ // WiFi
+ _writeNullTerminatedString(config, 75, wifiName, 20);
+ _writeNullTerminatedString(config, 95, wifiPassword, 20);
+
+ // 字节118位域 @115
+ int b118 = batteryType & 0x03;
+ b118 |= (walkDriveConfig & 0x03) << 2;
+ bd.setUint8(115, b118);
+
+ // 尺寸参数
+ bd.setFloat32(116, gearRatio, Endian.little);
+ bd.setFloat32(120, robotLength, Endian.little);
+ bd.setFloat32(124, robotWidth, Endian.little);
+ bd.setFloat32(128, robotHeight, Endian.little);
+ bd.setFloat32(132, knifeWidth, Endian.little);
+ bd.setFloat32(136, tireSize, Endian.little);
+
+ // 增益
+ bd.setFloat32(140, leftForwardGain, Endian.little);
+ bd.setFloat32(144, leftBackwardGain, Endian.little);
+ bd.setFloat32(148, rightForwardGain, Endian.little);
+ bd.setFloat32(152, rightBackwardGain, Endian.little);
+
+ // 固件版本
+ _writeNullTerminatedString(config, 156, firmwareVersion, 15);
+
+ return config;
+ }
+
+ /// 获取所有字段的 label→value 映射
+ Map toFieldMap() {
+ return {
+ 'UID固化标志': '$uidSolidifiedFlag',
+ '芯片UID': chipUid,
+ '割刀电机模式': knifeMotorMode == 0 ? '纯电' : '油电($knifeMotorMode)',
+ '行走电机模式': walkMotorMode == 0 ? '轮式' : '履带($walkMotorMode)',
+ '左轮极性': leftWheelPolarity ? '低' : '高',
+ '右轮极性': rightWheelPolarity ? '低' : '高',
+ '通道交换': channelSwap ? '是' : '否',
+ '联网目标': use4g ? '4G' : 'WiFi',
+ '前进速度限制': '$forwardSpeedLimit',
+ '转向速度限制': '$turnSpeedLimit',
+ '割刀通道极性': knifeChannelPolarity ? '低' : '高',
+ '风门通道极性': fanChannelPolarity ? '低' : '高',
+ '油门通道极性': throttleChannelPolarity ? '低' : '高',
+ '升降保护时间': '$liftProtectTime 秒',
+ 'RTK配置': dualRtk ? '双天线' : '单天线',
+ '割刀通道配置': '$knifeChannelConfig',
+ '风门通道配置': '$fanChannelConfig',
+ '油门通道配置': '$throttleChannelConfig',
+ '遥控器类型': '$remoteType',
+ '搭载继电器板': relayBoard ? '是' : '否',
+ '底盘升降通道': '$chassisLiftChannel',
+ '底盘通道': '$chassisChannel',
+ '机械臂通道': '$armChannel',
+ '燃油泵通道': '$fuelPumpChannel',
+ 'WiFi名称': wifiName,
+ 'WiFi密码': wifiPassword,
+ '电池类型': batteryType == 0 ? '铅酸' : '锂电',
+ '行走驱动': walkDriveConfig.toString(),
+ '转速比': gearRatio.toStringAsFixed(2),
+ '机器人长度': robotLength.toStringAsFixed(2),
+ '机器人宽度': robotWidth.toStringAsFixed(2),
+ '机器人高度': robotHeight.toStringAsFixed(2),
+ '割刀宽度': knifeWidth.toStringAsFixed(2),
+ '轮胎尺寸': tireSize.toStringAsFixed(2),
+ '左轮前进增益': leftForwardGain.toStringAsFixed(2),
+ '左轮后退增益': leftBackwardGain.toStringAsFixed(2),
+ '右轮前进增益': rightForwardGain.toStringAsFixed(2),
+ '右轮后退增益': rightBackwardGain.toStringAsFixed(2),
+ '固件版本': firmwareVersion,
+ };
+ }
+
+ /// 根据字段标签设置新值(返回 null 表示成功,返回错误原因字符串)
+ String? setField(String label, String value) {
+ final intVal = int.tryParse(value);
+ final doubleVal = double.tryParse(value);
+
+ switch (label) {
+ case 'UID固化标志':
+ if (intVal == null || intVal < 0 || intVal > 1) return '值范围: 0~1';
+ uidSolidifiedFlag = intVal;
+ return null;
+ case '前进速度限制':
+ if (intVal == null || intVal < 0 || intVal > 65535)
+ return '值范围: 0~65535';
+ forwardSpeedLimit = intVal;
+ return null;
+ case '转向速度限制':
+ if (intVal == null || intVal < 0 || intVal > 65535)
+ return '值范围: 0~65535';
+ turnSpeedLimit = intVal;
+ return null;
+ case 'WiFi名称':
+ if (value.length > 20) return '最多20字符';
+ wifiName = value;
+ return null;
+ case 'WiFi密码':
+ if (value.length > 20) return '最多20字符';
+ wifiPassword = value;
+ return null;
+ case '转速比':
+ if (doubleVal == null) return '请输入有效数字';
+ gearRatio = doubleVal;
+ return null;
+ case '机器人长度':
+ if (doubleVal == null) return '请输入有效数字';
+ robotLength = doubleVal;
+ return null;
+ case '机器人宽度':
+ if (doubleVal == null) return '请输入有效数字';
+ robotWidth = doubleVal;
+ return null;
+ case '机器人高度':
+ if (doubleVal == null) return '请输入有效数字';
+ robotHeight = doubleVal;
+ return null;
+ case '割刀宽度':
+ if (doubleVal == null) return '请输入有效数字';
+ knifeWidth = doubleVal;
+ return null;
+ case '轮胎尺寸':
+ if (doubleVal == null) return '请输入有效数字';
+ tireSize = doubleVal;
+ return null;
+ case '左轮前进增益':
+ if (doubleVal == null) return '请输入有效数字';
+ leftForwardGain = doubleVal;
+ return null;
+ case '左轮后退增益':
+ if (doubleVal == null) return '请输入有效数字';
+ leftBackwardGain = doubleVal;
+ return null;
+ case '右轮前进增益':
+ if (doubleVal == null) return '请输入有效数字';
+ rightForwardGain = doubleVal;
+ return null;
+ case '右轮后退增益':
+ if (doubleVal == null) return '请输入有效数字';
+ rightBackwardGain = doubleVal;
+ return null;
+ case '固件版本':
+ if (value.length > 15) return '最多15字符';
+ firmwareVersion = value;
+ return null;
+ default:
+ return '未知字段: $label';
+ }
+ }
+
+ // 工具:读取0结尾的ASCII字符串
+ static String _readNullTerminatedString(
+ Uint8List data,
+ int offset,
+ int maxLen,
+ ) {
+ final end = data.indexOf(0, offset);
+ final realEnd = (end == -1 || end > offset + maxLen)
+ ? offset + maxLen
+ : end;
+ return ascii.decode(data.sublist(offset, realEnd));
+ }
+
+ // 工具:写入ASCII字符串,不足补0
+ static void _writeNullTerminatedString(
+ Uint8List data,
+ int offset,
+ String str,
+ int maxLen,
+ ) {
+ final bytes = ascii.encode(str);
+ for (int i = 0; i < maxLen; i++) {
+ data[offset + i] = i < bytes.length ? bytes[i] : 0x00;
+ }
+ }
+}
diff --git a/lib/core/bluetooth/protocol_parser.dart b/lib/core/bluetooth/protocol_parser.dart
new file mode 100644
index 00000000..eaaf1b40
--- /dev/null
+++ b/lib/core/bluetooth/protocol_parser.dart
@@ -0,0 +1,122 @@
+import 'dart:typed_data';
+
+class BlePacket {
+ final int command;
+ final Uint8List payload;
+ const BlePacket({required this.command, required this.payload});
+}
+
+/// 有状态的 BLE 协议解析器
+/// 支持分片接收:多次调用 [append] 累积数据,[parse] 提取完整帧
+class ProtocolParser {
+ static const int _header1 = 0xAB;
+ static const int _header2 = 0xAA;
+ static const int _tail1 = 0xAA;
+ static const int _tail2 = 0xAB;
+
+ final List _buffer = [];
+
+ /// 清空缓冲区
+ void clear() {
+ _buffer.clear();
+ }
+
+ /// 获取缓冲区长度
+ int get bufferLength => _buffer.length;
+
+ /// 打包命令为字节帧(用于发送)
+ /// CRC16 覆盖 command + payload 确保整帧完整性
+ static Uint8List pack(int command, List payload) {
+ final dataToCheck = [command, ...payload];
+ final crc = _crc16(dataToCheck);
+ final builder = BytesBuilder()
+ ..addByte(_header1)
+ ..addByte(_header2)
+ ..addByte(command)
+ ..add(payload)
+ ..addByte(crc & 0xFF)
+ ..addByte((crc >> 8) & 0xFF)
+ ..addByte(_tail1)
+ ..addByte(_tail2);
+ return builder.takeBytes();
+ }
+
+ /// 追加新接收的数据到缓冲区
+ void append(List data) {
+ _buffer.addAll(data);
+ }
+
+ /// 从缓冲区解析所有完整的数据包
+ /// 未完成的帧保留在缓冲区等待后续数据
+ List parse() {
+ final List packets = [];
+
+ while (_buffer.length >= 6) {
+ // 1. 查找帧头
+ int headIdx = -1;
+ for (int i = 0; i < _buffer.length - 1; i++) {
+ if (_buffer[i] == _header1 && _buffer[i + 1] == _header2) {
+ headIdx = i;
+ break;
+ }
+ }
+
+ if (headIdx == -1) {
+ // 无有效帧头,清空缓冲区
+ _buffer.clear();
+ break;
+ }
+
+ // 移除帧头前的垃圾数据
+ if (headIdx > 0) _buffer.removeRange(0, headIdx);
+
+ // 2. 查找帧尾
+ int tailIdx = -1;
+ for (int i = 2; i < _buffer.length - 1; i++) {
+ if (_buffer[i] == _tail1 && _buffer[i + 1] == _tail2) {
+ tailIdx = i;
+ break;
+ }
+ }
+
+ if (tailIdx == -1) {
+ // 帧尾未找到,可能是分片数据,等待更多数据
+ break;
+ }
+
+ // 3. 提取数据包
+ if (_buffer.length >= 5) {
+ final cmd = _buffer[2];
+ final payload = Uint8List.fromList(_buffer.sublist(3, tailIdx));
+ packets.add(BlePacket(command: cmd, payload: payload));
+ }
+
+ // 4. 移除已处理的帧
+ _buffer.removeRange(0, tailIdx + 2);
+ }
+
+ return packets;
+ }
+
+ /// 便捷方法:追加数据并立即解析
+ List appendAndParse(List data) {
+ append(data);
+ return parse();
+ }
+
+ /// CRC16-Modbus: polynomial=0x8005, init=0xFFFF, refIn/refOut=true
+ static int _crc16(List data) {
+ int crc = 0xFFFF;
+ for (final b in data) {
+ crc ^= b;
+ for (int j = 0; j < 8; j++) {
+ if ((crc & 0x0001) != 0) {
+ crc = (crc >> 1) ^ 0xA001;
+ } else {
+ crc >>= 1;
+ }
+ }
+ }
+ return crc;
+ }
+}
diff --git a/lib/core/consts/http_api_consts.dart b/lib/core/consts/http_api_consts.dart
index 8c6ce35e..41b6f8b2 100644
--- a/lib/core/consts/http_api_consts.dart
+++ b/lib/core/consts/http_api_consts.dart
@@ -11,14 +11,14 @@ class HttpApiConsts {
// 获取设备列表
static const String getUserDevicesList = "$baseUrl/iot/device/list";
// 绑定设备
- static const String bindDevice = "$baseUrl/forward/device/bind";
+ static const String bindDevice = "$baseUrl/iot/device/bind";
// 解绑设备
static const String unbindDevice = "$baseUrl/forward/device/unbind";
// 切换设备
static const String switchDevice = "$baseUrl/forward/device/switchDevice";
// 获取光伏电站列表
- static const String getSiteList = "$baseUrl/system/site/list";
+ static const String getSiteList = "$baseUrl/system/site/selectByUserId";
// 获取场站下的设备列表
static const String getSiteDeviceList = "$baseUrl/iot/device/getSiteList";
@@ -42,7 +42,8 @@ class HttpApiConsts {
static const String getFlightTask = "$baseUrl/iot/UAV/getFlightTask";
// 获取飞行任务详情
- static const String getFlightTaskDetail = "$baseUrl/iot/UAV/getFlightTaskDetail";
+ static const String getFlightTaskDetail =
+ "$baseUrl/iot/UAV/getFlightTaskDetail";
// 获取航线列表
static const String getWayline = "$baseUrl/iot/UAV/getWayline";
@@ -51,7 +52,8 @@ class HttpApiConsts {
static const String createFlightTask = "$baseUrl/iot/UAV/createFlightTask";
// 更新飞行任务状态
- static const String updateFlightTaskStatus = "$baseUrl/iot/UAV/updateFlightTaskStatus";
+ static const String updateFlightTaskStatus =
+ "$baseUrl/iot/UAV/updateFlightTaskStatus";
// 切换无人机镜头获取视频流
static const String changeUAVLens = "$baseUrl/iot/UAV/changeLens";
@@ -61,4 +63,56 @@ class HttpApiConsts {
// 飞行任务命令控制(暂停、返航等)
static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand";
+
+ /// 告警相关
+ static const String alarmBaseUrl = "http://1.95.137.212:8081";
+ // 获取告警工单配置列表
+ static const String alarmOrderConfigList =
+ "$alarmBaseUrl/iot/alarmOrderConfig/list";
+ // 获取告警列表
+ static const String alarmList = "$baseUrl/iot/alarm/list";
+ // 获取告警详情
+ static const String alarmDetail = "$baseUrl/iot/alarm";
+ // 处理告警(确认/关闭等)
+ static const String alarmHandle = "$baseUrl/iot/alarm/handle";
+
+ /// 工单相关
+ // 获取工单模型配置列表
+ static const String orderModelList = "$baseUrl/iot/orderModel/list";
+ // 添加工单(上报)
+ static const String workOrderAdd = "$baseUrl/iot/ioTworkOrder/add";
+ // 获取工单列表
+ static const String workOrderList = "$baseUrl/iot/ioTworkOrder/list";
+ // 获取工单详情
+ static const String workOrderDetail = "$baseUrl/iot/ioTworkOrder";
+ // 派发工单
+ static const String workOrderDispat = "$baseUrl/iot/ioTworkOrder/dispatch";
+ // 挂起工单
+ static const String workOrderSuspend = "$baseUrl/iot/ioTworkOrder/suspend";
+ // 完成工单
+ static const String workOrderComplete = "$baseUrl/iot/ioTworkOrder/complete";
+ // 开始执行工单
+ static const String workOrderStart = "$baseUrl/iot/ioTworkOrder/start";
+ // 获取工单统计
+ static const String workOrderCount = "$baseUrl/iot/ioTworkOrder/count";
+ // 设备运行参数查询
+ static const String deviceRunParamSelect =
+ "$baseUrl/iot/deviceRunParam/selectByDeviceId";
+ // 设备运行参数保存
+ static const String deviceRunParamSave = "$baseUrl/iot/deviceRunParam/save";
+
+ // 设备操作权限校验
+ static const String hasPermission = "$baseUrl/iot/device/hasPermission";
+
+ /// 用户相关
+ // 获取用户列表
+ static const String systemUserList = "$baseUrl/system/user/list";
+
+ /// 组织与场站相关
+ // 获取组织列表
+ static const String orgList = "$baseUrl/system/org/list";
+ // 根据组织ID获取场站列表
+ static const String siteListByOrgId = "$baseUrl/system/site/selectByOrgId";
+ // 根据场站ID获取用户列表
+ static const String userListBySiteId = "$baseUrl/system/user/list";
}
diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart
index 29d078ee..9e6e7ed4 100644
--- a/lib/core/di/injection.dart
+++ b/lib/core/di/injection.dart
@@ -52,15 +52,12 @@ import '../../features/devices/domain/usecases/get_device_location_usecase.dart'
import '../../features/devices/domain/usecases/get_work_record_usecase.dart';
import '../../features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart';
import '../../features/devices/domain/usecases/create_device_task_usecase.dart';
-import '../../features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart';
import '../../features/devices/domain/usecases/route_planning_usecase.dart';
import '../../features/devices/domain/usecases/save_work_record_usecase.dart';
-import '../../features/devices/domain/usecases/select_work_record_usecase.dart';
import '../../features/devices/domain/usecases/switch_device_usecase.dart';
import '../../features/devices/presentation/bloc/device_status_bloc.dart';
import '../../features/remote_control/data/datasources/remote_http_datasource.dart';
import '../../features/remote_control/data/datasources/remote_tcp_datasource.dart';
-import '../../features/remote_control/domain/usecase/remote_control_usecase.dart';
import '../../features/remote_control/presentation/bloc/remote_control_cubit.dart';
import '../../features/v2/home/data/datasources/home_remote_datasource.dart';
import '../../features/v2/home/data/datasources/site_datasource.dart';
@@ -96,6 +93,12 @@ import '../../features/v2/device_list/domain/usecases/return_home_usecase.dart';
import '../../features/v2/device_list/presentation/bloc/drone_station_bloc.dart';
import '../../features/v2/device_list/presentation/bloc/robot_list_bloc.dart';
import '../../features/v2/device_list/presentation/bloc/device_realtime_bloc.dart';
+import '../../features/v2/device_list/data/datasources/bind_device_datasource.dart';
+import '../../features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart';
+import '../../features/v2/device_list/data/repositories/bind_device_repository_impl.dart';
+import '../../features/v2/device_list/domain/repositories/bind_device_repository.dart';
+import '../../features/v2/device_list/domain/usecases/bind_device_usecases.dart';
+import '../../features/v2/device_list/presentation/cubit/bind_device_cubit.dart';
import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart';
import '../../features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart';
import '../../features/v2/waring_center/data/repositories/alarm_repository_impl.dart';
@@ -116,6 +119,18 @@ import '../../features/v2/message_center/data/repositories/message_center_reposi
import '../../features/v2/message_center/domain/repositories/message_center_repository.dart';
import '../../features/v2/message_center/domain/usecases/message_center_usecase.dart';
import '../../features/v2/message_center/presentation/bloc/message_center_cubit.dart';
+import '../../features/v2/work_order/data/datasources/work_order_remote_datasource.dart';
+import '../../features/v2/work_order/data/datasources/work_order_remote_datasource_impl.dart';
+import '../../features/v2/work_order/data/repositories/work_order_repository_impl.dart';
+import '../../features/v2/work_order/domain/repositories/work_order_repository.dart';
+import '../../features/v2/work_order/domain/usecases/work_order_usecases.dart';
+import '../../features/v2/work_order/presentation/cubit/work_order_cubit.dart';
+import '../../features/v2/device_run_param/data/datasources/device_run_param_remote_datasource.dart';
+import '../../features/v2/device_run_param/data/datasources/device_run_param_remote_datasource_impl.dart';
+import '../../features/v2/device_run_param/data/repositories/device_run_param_repository_impl.dart';
+import '../../features/v2/device_run_param/domain/repositories/device_run_param_repository.dart';
+import '../../features/v2/device_run_param/domain/usecases/device_run_param_usecases.dart';
+import '../services/device_permission_service.dart';
import '../../features/devices/data/datasources/device_task_datasource.dart';
import '../../features/devices/data/repositories/device_task_repository_impl.dart';
import '../../features/devices/domain/repositories/device_task_repository.dart';
@@ -196,7 +211,9 @@ Future init() async {
sl.registerLazySingleton(() => SentryLoggerImpl());
/// 1.4 --- Route Observer (路由监听器) ---
- sl.registerLazySingleton(() => RouteObserver>());
+ sl.registerLazySingleton(
+ () => RouteObserver>(),
+ );
/// 1.5 --- MQTT Data Sources ---
sl.registerFactory(
@@ -367,9 +384,7 @@ Future init() async {
sl.registerLazySingleton(
() => PauseFlightTaskUseCase(sl()),
);
- sl.registerLazySingleton(
- () => ReturnHomeUseCase(sl()),
- );
+ sl.registerLazySingleton(() => ReturnHomeUseCase(sl()));
sl.registerFactory(
() => DroneStationBloc(sl(), sl(), sl(), sl()),
);
@@ -384,7 +399,7 @@ Future init() async {
/// Alarm Center V2
sl.registerLazySingleton(
- () => AlarmRemoteDataSourceImpl(),
+ () => AlarmRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton(() => AlarmRepositoryImpl(sl()));
sl.registerLazySingleton(
@@ -393,13 +408,11 @@ Future init() async {
sl.registerLazySingleton(
() => GetAlarmCountUseCase(sl()),
);
- sl.registerFactory(
- () => AlarmCubit(getAlarmListUseCase: sl(), getAlarmCountUseCase: sl()),
- );
+ sl.registerFactory(() => AlarmCubit(getAlarmListUseCase: sl()));
/// Alarm Detail V2
sl.registerLazySingleton(
- () => AlarmDetailRemoteDataSourceImpl(),
+ () => AlarmDetailRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton(
() => AlarmDetailRepositoryImpl(sl()),
@@ -410,11 +423,13 @@ Future init() async {
sl.registerLazySingleton(
() => ConfirmAlarmUseCase(sl()),
);
+ sl.registerLazySingleton(() => HandleAlarmUseCase(sl()));
sl.registerLazySingleton(() => AIDiagnosisUseCase(sl()));
sl.registerFactory(
() => AlarmDetailCubit(
getAlarmDetailUseCase: sl(),
confirmAlarmUseCase: sl(),
+ handleAlarmUseCase: sl(),
aiDiagnosisUseCase: sl(),
),
);
@@ -565,5 +580,84 @@ Future init() async {
sl.registerLazySingleton(() => CancelTaskUseCase(sl()));
sl.registerLazySingleton(() => PauseTaskUseCase(sl()));
sl.registerLazySingleton(() => RecoveryTaskUseCase(sl()));
- sl.registerFactory(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
+ sl.registerLazySingleton(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
+
+ /// 11. 工单管理 (Work Order)
+ sl.registerLazySingleton(
+ () => WorkOrderRemoteDataSourceImpl(sl()),
+ );
+ sl.registerLazySingleton(
+ () => WorkOrderRepositoryImpl(remoteDataSource: sl()),
+ );
+ sl.registerLazySingleton(
+ () => GetWorkOrderListUseCase(sl()),
+ );
+ sl.registerLazySingleton(
+ () => GetWorkOrderDetailUseCase(sl()),
+ );
+ sl.registerLazySingleton(
+ () => DispatchWorkOrderUseCase(sl()),
+ );
+ sl.registerLazySingleton(
+ () => SuspendWorkOrderUseCase(sl()),
+ );
+ sl.registerLazySingleton(
+ () => CompleteWorkOrderUseCase(sl()),
+ );
+ sl.registerLazySingleton(
+ () => StartWorkOrderUseCase(sl()),
+ );
+ sl.registerFactory(
+ () => WorkOrderCubit(
+ getWorkOrderListUseCase: sl(),
+ getWorkOrderDetailUseCase: sl(),
+ dispatchWorkOrderUseCase: sl(),
+ suspendWorkOrderUseCase: sl(),
+ completeWorkOrderUseCase: sl(),
+ startWorkOrderUseCase: sl(),
+ ),
+ );
+
+ /// 12. 设备运行参数管理 (Device Run Param)
+ sl.registerLazySingleton(
+ () => DeviceRunParamRemoteDataSourceImpl(sl()),
+ );
+ sl.registerLazySingleton(
+ () => DeviceRunParamRepositoryImpl(remoteDataSource: sl()),
+ );
+ sl.registerLazySingleton(
+ () => GetDeviceRunParamUseCase(sl()),
+ );
+ sl.registerLazySingleton(
+ () => SaveDeviceRunParamUseCase(sl()),
+ );
+
+ /// 13. 设备操作权限校验服务 (Device Permission Service)
+ sl.registerLazySingleton(
+ () => DevicePermissionService(sl()),
+ );
+
+ /// 14. 绑定智能装备 (Bind Device)
+ sl.registerLazySingleton(
+ () => BindDeviceDatasourceImpl(sl()),
+ );
+ sl.registerLazySingleton(
+ () => BindDeviceRepositoryImpl(sl()),
+ );
+ sl.registerLazySingleton(() => GetOrgListUseCase(sl()));
+ sl.registerLazySingleton(
+ () => GetSitesByOrgUseCase(sl()),
+ );
+ sl.registerLazySingleton(
+ () => GetUsersBySiteUseCase(sl()),
+ );
+ sl.registerLazySingleton(
+ () => BindDeviceV2UseCase(sl()),
+ );
+ sl.registerLazySingleton(
+ () => IsDeviceAtSiteUseCase(sl()),
+ );
+ sl.registerFactory(
+ () => BindDeviceCubit(sl(), sl(), sl(), sl(), sl(), sl()),
+ );
}
diff --git a/lib/core/domain/entities/user_entity.dart b/lib/core/domain/entities/user_entity.dart
index 61c4f31f..9a4cc452 100644
--- a/lib/core/domain/entities/user_entity.dart
+++ b/lib/core/domain/entities/user_entity.dart
@@ -5,12 +5,14 @@ class UserEntity extends Equatable {
final String username;
final String nickname;
final String token;
- final int orgId; // 组织ID,用于获取场站列表
+ final int orgId;
final String? avatar;
final String? email;
final String? phone;
+ final String? roleKey;
+ final int? siteId;
- UserEntity({
+ const UserEntity({
required this.userId,
required this.username,
required this.nickname,
@@ -19,6 +21,8 @@ class UserEntity extends Equatable {
this.avatar,
this.email,
this.phone,
+ this.roleKey,
+ this.siteId,
});
@override
@@ -31,5 +35,33 @@ class UserEntity extends Equatable {
orgId,
email,
phone,
+ roleKey,
+ siteId,
];
-}
+
+ UserEntity copyWith({
+ String? userId,
+ String? username,
+ String? nickname,
+ String? token,
+ int? orgId,
+ String? avatar,
+ String? email,
+ String? phone,
+ String? roleKey,
+ int? siteId,
+ }) {
+ return UserEntity(
+ userId: userId ?? this.userId,
+ username: username ?? this.username,
+ nickname: nickname ?? this.nickname,
+ token: token ?? this.token,
+ orgId: orgId ?? this.orgId,
+ avatar: avatar ?? this.avatar,
+ email: email ?? this.email,
+ phone: phone ?? this.phone,
+ roleKey: roleKey ?? this.roleKey,
+ siteId: siteId ?? this.siteId,
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/core/managers/drone_task_state_manager.dart b/lib/core/managers/drone_task_state_manager.dart
index 321954b5..43e0af52 100644
--- a/lib/core/managers/drone_task_state_manager.dart
+++ b/lib/core/managers/drone_task_state_manager.dart
@@ -1,6 +1,24 @@
+import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../features/v2/device_list/domain/entities/drone_station_entity.dart';
+/// 无人机轨迹点(轻量级,避免在核心层引入 latlong2 依赖)
+class DroneTrajectoryPoint {
+ final double latitude;
+ final double longitude;
+ final double? heading;
+
+ const DroneTrajectoryPoint({
+ required this.latitude,
+ required this.longitude,
+ this.heading,
+ });
+
+ @override
+ String toString() =>
+ 'DroneTrajectoryPoint(lat=$latitude, lng=$longitude, heading=$heading)';
+}
+
/// 无人机任务信息
class DroneTaskInfo {
final String droneSn;
@@ -33,9 +51,33 @@ class DroneTaskStateManager {
final ValueNotifier _currentTaskInfo =
ValueNotifier(null);
+ /// 任务下发成功后,等待无人机 OSD 推送数据(实时信息)
+ final ValueNotifier _isWaitingForOsdPush = ValueNotifier(false);
+
+ /// 任务下发成功后,等待无人机视频流
+ final ValueNotifier _isWaitingForVideo = ValueNotifier(false);
+
+ /// 无人机飞行轨迹点(跨页面持久化,退出视频页后不丢失)
+ final ValueNotifier> _trajectoryPoints =
+ ValueNotifier>([]);
+
+ /// 超时定时器,避免一直转圈(推送/视频迟迟不到)
+ Timer? _osdWaitTimeoutTimer;
+ Timer? _videoWaitTimeoutTimer;
+
/// 监听无人机任务信息变化
ValueListenable get currentTaskInfo => _currentTaskInfo;
+ /// 监听“等待 OSD 推送”状态
+ ValueListenable get isWaitingForOsdPush => _isWaitingForOsdPush;
+
+ /// 监听“等待视频流”状态
+ ValueListenable get isWaitingForVideo => _isWaitingForVideo;
+
+ /// 监听无人机轨迹点
+ ValueListenable> get trajectoryPoints =>
+ _trajectoryPoints;
+
/// 获取当前无人机任务信息
DroneTaskInfo? get currentInfo => _currentTaskInfo.value;
@@ -50,10 +92,84 @@ class DroneTaskStateManager {
_currentTaskInfo.value = info;
}
+ /// 任务下发成功后调用:开始监测无人机实时推送与视频流
+ /// 在接口返回成功之后调用
+ void markTaskIssued() {
+ debugPrint('🚀 [DroneTaskStateManager] 任务已下发,开始监测推送数据与视频流');
+ _isWaitingForOsdPush.value = true;
+ _isWaitingForVideo.value = true;
+
+ _osdWaitTimeoutTimer?.cancel();
+ _videoWaitTimeoutTimer?.cancel();
+
+ // 超时兜底:90 秒后仍未收到 OSD 推送,自动停止转圈
+ _osdWaitTimeoutTimer = Timer(const Duration(seconds: 90), () {
+ if (_isWaitingForOsdPush.value) {
+ debugPrint('⚠️ [DroneTaskStateManager] 等待 OSD 推送超时,自动停止');
+ _isWaitingForOsdPush.value = false;
+ }
+ });
+
+ // 超时兜底:90 秒后仍未收到视频,自动停止 toast
+ _videoWaitTimeoutTimer = Timer(const Duration(seconds: 90), () {
+ if (_isWaitingForVideo.value) {
+ debugPrint('⚠️ [DroneTaskStateManager] 等待视频流超时,自动停止');
+ _isWaitingForVideo.value = false;
+ }
+ });
+ }
+
+ /// 收到无人机 OSD 推送数据后调用:停止转圈,恢复绿点
+ void markOsdPushReceived() {
+ if (_isWaitingForOsdPush.value) {
+ debugPrint('✅ [DroneTaskStateManager] 收到 OSD 推送数据,停止监测');
+ _isWaitingForOsdPush.value = false;
+ _osdWaitTimeoutTimer?.cancel();
+ _osdWaitTimeoutTimer = null;
+ }
+ }
+
+ /// 收到无人机视频流后调用:隐藏"视频获取中" toast
+ void markVideoReceived() {
+ if (_isWaitingForVideo.value) {
+ debugPrint('✅ [DroneTaskStateManager] 收到视频流,停止监测');
+ _isWaitingForVideo.value = false;
+ _videoWaitTimeoutTimer?.cancel();
+ _videoWaitTimeoutTimer = null;
+ }
+ }
+
+ /// 添加无人机轨迹点
+ void addTrajectoryPoint(DroneTrajectoryPoint point) {
+ final list = List.from(_trajectoryPoints.value);
+ list.add(point);
+ // 性能优化:只保留最近 1000 个点
+ if (list.length > 1000) {
+ list.removeAt(0);
+ }
+ _trajectoryPoints.value = list;
+ }
+
+ /// 批量设置轨迹点(恢复历史轨迹时使用)
+ void setTrajectoryPoints(List points) {
+ _trajectoryPoints.value = List.from(points);
+ }
+
+ /// 清空轨迹
+ void clearTrajectory() {
+ _trajectoryPoints.value = [];
+ }
+
/// 清除无人机任务信息
void clearDroneTaskInfo() {
debugPrint('🛸 [DroneTaskStateManager] 清除无人机任务信息');
_currentTaskInfo.value = null;
+ _osdWaitTimeoutTimer?.cancel();
+ _videoWaitTimeoutTimer?.cancel();
+ _osdWaitTimeoutTimer = null;
+ _videoWaitTimeoutTimer = null;
+ _isWaitingForOsdPush.value = false;
+ _isWaitingForVideo.value = false;
}
/// 检查是否有当前任务
diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart
index 69824887..6906010f 100644
--- a/lib/core/network/dio_client.dart
+++ b/lib/core/network/dio_client.dart
@@ -15,7 +15,13 @@ class DioClient {
),
);
- dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
+ dio.interceptors.add(
+ LogInterceptor(
+ requestBody: true,
+ responseBody: true,
+ requestHeader: true,
+ ),
+ );
dio.interceptors.add(
InterceptorsWrapper(
@@ -30,23 +36,29 @@ class DioClient {
return handler.next(options);
},
- // ======================
- // ✅ 关键:401 自动拦截
- // ======================
onResponse: (response, handler) {
+ if (response.data is Map) {
+ final code = response.data['code'];
+ if (code == 401 || code == 403) {
+ print(
+ '>>> [DIO] 🚨🚨🚨 收到业务错误码 $code,触发 Token 过期处理!URL: ${response.requestOptions.uri},时间: ${DateTime.now()}',
+ );
+ try {
+ sl().tokenExpired();
+ } catch (ex) {}
+ }
+ }
return handler.next(response);
},
onError: (DioException e, handler) async {
- // 401 = token 过期 / 未授权
- if (e.response?.statusCode == 401) {
- print('>>> [DIO] 🚨🚨🚨 收到 401 响应,触发自动退出登录!URL: ${e.requestOptions.uri},时间: ${DateTime.now()}');
+ if (e.response?.statusCode == 401 || e.response?.statusCode == 403) {
+ print(
+ '>>> [DIO] 🚨🚨🚨 收到 HTTP ${e.response?.statusCode},触发 Token 过期处理!URL: ${e.requestOptions.uri},时间: ${DateTime.now()}',
+ );
try {
- // 调用 logout 清除本地缓存 + 跳登录
- await sl().logout();
- } catch (ex) {
- // 防止报错
- }
+ sl().tokenExpired();
+ } catch (ex) {}
}
return handler.next(e);
diff --git a/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart
index 4d86ec65..51d2079f 100644
--- a/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart
+++ b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart
@@ -28,6 +28,8 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
StreamSubscription? _subscription;
String? _deviceSn;
String? _gatewaySn;
+ int _listenerCount = 0;
+ bool _isDisposed = false;
DroneOsdDataSourceImpl(this.mqttClient);
@@ -42,10 +44,14 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
required String deviceSn,
required String gatewaySn,
}) async {
- if (_deviceSn == deviceSn &&
+ if (_isDisposed) return;
+
+ if (_listenerCount > 0 &&
+ _deviceSn == deviceSn &&
_gatewaySn == gatewaySn &&
_subscription != null) {
- debugPrint('[DroneOsdDataSource] already listening');
+ _listenerCount++;
+ debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount');
return;
}
@@ -73,10 +79,20 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
_subscription = mqttClient.messageStream?.listen((message) {
_handleMessage(message);
});
+
+ _listenerCount = 1;
}
@override
Future stopListening() async {
+ _listenerCount--;
+ if (_listenerCount > 0) {
+ debugPrint('[DroneOsdDataSource] 引用计数-1: $_listenerCount (保留订阅)');
+ return;
+ }
+
+ _listenerCount = 0;
+
await _subscription?.cancel();
_subscription = null;
@@ -94,26 +110,19 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
}
void _handleMessage(MqttMessage message) {
+ if (_isDisposed) return;
try {
final jsonData = jsonDecode(message.payload) as Map;
final osdData = DroneOsdEntity.fromJson(jsonData);
- // 🔥 重要修复:先判断 gatewaySn,再判断 deviceSn
- // 因为 topic 可能同时包含两者,但我们需要优先匹配机场
if (_gatewaySn != null &&
_gatewaySn!.isNotEmpty &&
message.topic.contains(_gatewaySn!)) {
- debugPrint('🏢 [DroneOsdDataSource] 机场 OSD 更新');
- debugPrint('🏢 [DroneOsdDataSource] Topic: ${message.topic}');
_stationOsdController.add(osdData);
} else if (_deviceSn != null &&
_deviceSn!.isNotEmpty &&
message.topic.contains(_deviceSn!)) {
- debugPrint('🛸 [DroneOsdDataSource] 无人机 OSD 更新');
- debugPrint('🛸 [DroneOsdDataSource] Topic: ${message.topic}');
_droneOsdController.add(osdData);
- } else {
- debugPrint('⚠️ [DroneOsdDataSource] 未知设备类型,Topic: ${message.topic}');
}
} catch (e) {
debugPrint('❌ [DroneOsdDataSource] 解析 OSD 数据失败: $e');
@@ -121,8 +130,9 @@ class DroneOsdDataSourceImpl implements DroneOsdDataSource {
}
void dispose() {
+ _isDisposed = true;
stopListening();
_droneOsdController.close();
_stationOsdController.close();
}
-}
+}
\ No newline at end of file
diff --git a/lib/core/network/mqtt/data/datasources/task_message_datasource.dart b/lib/core/network/mqtt/data/datasources/task_message_datasource.dart
index de4ab186..29695cff 100644
--- a/lib/core/network/mqtt/data/datasources/task_message_datasource.dart
+++ b/lib/core/network/mqtt/data/datasources/task_message_datasource.dart
@@ -13,7 +13,7 @@ abstract class TaskMessageDataSource {
Stream get taskArriveStream;
Stream get realTimeMessageStream;
- Future startListening({required String deviceId});
+ Future startListening({required String deviceId, int? taskId});
Future stopListening();
}
@@ -26,6 +26,7 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
StreamSubscription? _subscription;
String? _deviceId;
+ int? _taskId;
TaskMessageDataSourceImpl(this.mqttClient);
@@ -40,9 +41,9 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
_realTimeMessageController.stream;
@override
- Future startListening({required String deviceId}) async {
- if (_deviceId == deviceId && _subscription != null) {
- debugPrint('[TaskMessageDataSource] already listening: $deviceId');
+ Future startListening({required String deviceId, int? taskId}) async {
+ if (_deviceId == deviceId && _taskId == taskId && _subscription != null) {
+ debugPrint('[TaskMessageDataSource] already listening: deviceId=$deviceId, taskId=$taskId');
return;
}
@@ -54,18 +55,20 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
}
_deviceId = deviceId;
+ _taskId = taskId;
- final taskStatusTopic = 'task/$deviceId/status';
- final taskArriveTopic = 'task/$deviceId/arrive';
+ // 🔥 status/arrive 话题使用 taskId,realTimeMessage 使用 deviceId
+ final taskStatusTopic = taskId != null ? 'task/$taskId/status' : null;
+ final taskArriveTopic = taskId != null ? 'task/$taskId/arrive' : null;
final realTimeTopic = 'device/$deviceId/realTimeMessage';
debugPrint('📋 [TaskMessageDataSource] 开始监听:');
- debugPrint(' 任务状态: $taskStatusTopic');
- debugPrint(' 到达通知: $taskArriveTopic');
+ if (taskStatusTopic != null) debugPrint(' 任务状态: $taskStatusTopic');
+ if (taskArriveTopic != null) debugPrint(' 到达通知: $taskArriveTopic');
debugPrint(' 实时消息: $realTimeTopic');
- await mqttClient.subscribe(taskStatusTopic);
- await mqttClient.subscribe(taskArriveTopic);
+ if (taskStatusTopic != null) await mqttClient.subscribe(taskStatusTopic);
+ if (taskArriveTopic != null) await mqttClient.subscribe(taskArriveTopic);
await mqttClient.subscribe(realTimeTopic);
_subscription = mqttClient.messageStream?.listen((message) {
@@ -80,12 +83,16 @@ class TaskMessageDataSourceImpl implements TaskMessageDataSource {
if (_deviceId != null) {
final deviceId = _deviceId!;
- await mqttClient.unsubscribe('task/$deviceId/status');
- await mqttClient.unsubscribe('task/$deviceId/arrive');
+ // 🔥 status/arrive 用 taskId 取消订阅
+ if (_taskId != null) {
+ await mqttClient.unsubscribe('task/$_taskId/status');
+ await mqttClient.unsubscribe('task/$_taskId/arrive');
+ }
await mqttClient.unsubscribe('device/$deviceId/realTimeMessage');
}
_deviceId = null;
+ _taskId = null;
}
void _handleMessage(MqttMessage message) {
diff --git a/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart b/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart
index 49723915..9466bb3f 100644
--- a/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart
+++ b/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart
@@ -24,9 +24,9 @@ class TaskMessageRepositoryImpl implements TaskMessageRepository {
Stream get taskStatusStream => dataSource.taskStatusStream;
@override
- Future> startListening({required String deviceId}) async {
+ Future> startListening({required String deviceId, int? taskId}) async {
try {
- await dataSource.startListening(deviceId: deviceId);
+ await dataSource.startListening(deviceId: deviceId, taskId: taskId);
return right(null);
} catch (e) {
return left(Failure(e.toString()));
diff --git a/lib/core/network/mqtt/domain/models/mqtt_config.dart b/lib/core/network/mqtt/domain/models/mqtt_config.dart
index 3bfb0e7a..49bf5051 100644
--- a/lib/core/network/mqtt/domain/models/mqtt_config.dart
+++ b/lib/core/network/mqtt/domain/models/mqtt_config.dart
@@ -71,7 +71,7 @@ class MqttConfig extends Equatable {
factory MqttConfig.taskMessage() {
return const MqttConfig(
host: '1.95.137.212',
- port: 59020,
+ port: 1883,
username: 'maibu',
password: 'jsmbzn520',
protocol: MqttProtocol.tcp,
diff --git a/lib/core/network/mqtt/domain/repositories/task_message_repository.dart b/lib/core/network/mqtt/domain/repositories/task_message_repository.dart
index ab99c41b..25f1cacc 100644
--- a/lib/core/network/mqtt/domain/repositories/task_message_repository.dart
+++ b/lib/core/network/mqtt/domain/repositories/task_message_repository.dart
@@ -11,6 +11,6 @@ abstract class TaskMessageRepository {
Stream get taskArriveStream;
Stream get taskStatusStream;
- Future> startListening({required String deviceId});
+ Future> startListening({required String deviceId, int? taskId});
Future stopListening();
}
diff --git a/lib/core/network/tcp/tcp_client.dart b/lib/core/network/tcp/tcp_client.dart
index 3acc1dc7..ae81427f 100644
--- a/lib/core/network/tcp/tcp_client.dart
+++ b/lib/core/network/tcp/tcp_client.dart
@@ -36,9 +36,15 @@ class TcpClient {
final ILoggerService _logger = GetIt.I();
- // 新增:心跳定时器
+ // 心跳超时定时器(收到服务端心跳后重置,超时则判定连接断开)
Timer? _heartbeatTimer;
+ Timer? _heartbeatSendTimer; // 🔥 主动发送心跳定时器:每5秒给服务端发一次,防止服务端判定客户端失效
Timer? _reconnectTimer; // 重连定时器
+ int _reconnectAttempt = 0; // 🔥 重连尝试次数,用于退避计算
+ int _reconnectFailCount = 0; // 🔥 连续重连失败次数(含心跳超时),达到阈值后停止重连
+ /// 🔥 重连耗尽回调:连续4次重连失败(含心跳超时)后触发,通知上层弹窗提示用户
+ VoidCallback? onReconnectExhausted;
+ static const Duration _heartbeatTimeout = Duration(seconds: 15); // 心跳超时时间
String? _lastHost;
int? _lastPort;
@@ -46,6 +52,7 @@ class TcpClient {
bool _isSwitching = false;
bool _connecting = false; // 🔥 是否正在建立连接(防止 connect() 和 connectBySwitch() 并发竞争)
Completer? _connectionCompleter; // 🔥 用于通知连接就绪
+ int _connectionId = 0; // 🔥 连接代数:每次新建连接递增,旧socket回调发现ID不匹配则忽略
// 🔥 关键标志:记录客户端最后一次发送 0x03 认证包的时间
// 用于区分"自身认证触发的 have_logged_in"和"真正的异地登录"
@@ -95,6 +102,7 @@ class TcpClient {
bool get isConnected => _socket != null;
void disconnects({bool forSwitch = false}) {
+ _connectionId++; // 🔥 使旧socket回调失效
if (forSwitch) {
_isSwitching = true;
//debugPrint('🚫 [TCP] 标记为切换断开,将禁止自动重连');
@@ -103,7 +111,7 @@ class TcpClient {
// 🔥 关键:立即取消可能已经存在或即将触发的重连定时器
_reconnectTimer?.cancel();
- _heartbeatTimer?.cancel();
+ stopHeartbeat();
if (_socket != null) {
//debugPrint('🔌 [TCP] 物理断开 Socket...');
@@ -123,6 +131,7 @@ class TcpClient {
/// 🔥 彻底断开 TCP(用于退出登录),清除所有重连能力
void forceDisconnect() {
_logger.logWithLevel('🛑 [TCP] 强制断开并清除重连能力', shouldLog: true);
+ _connectionId++; // 🔥 使旧socket回调失效
// 1. 停止心跳
stopHeartbeat();
@@ -141,6 +150,8 @@ class TcpClient {
_connecting = false;
_connectionCompleter = null;
_lastAuthPacketSentAt = null;
+ _reconnectAttempt = 0; // 🔥 重置重连退避
+ _reconnectFailCount = 0; // 🔥 重置重连失败计数
// 5. 销毁 Socket
if (_socket != null) {
@@ -187,7 +198,9 @@ class TcpClient {
_connecting = true;
_connectionCompleter = Completer();
- debugPrint('🔌 [TCP-connect] 设置 _connecting=true');
+ _connectionId++; // 🔥 新一代连接,旧socket的回调将自动失效
+ final myConnId = _connectionId;
+ debugPrint('🔌 [TCP-connect] 设置 _connecting=true, connectionId=$myConnId');
_logger.logWithLevel('🔌 [TCP] 开始连接:$host:$port', shouldLog: true);
@@ -207,6 +220,9 @@ class TcpClient {
timeout: const Duration(seconds: 5),
);
_connecting = false; // 🔥 连接成功,重置标志
+ // 🔥 关键:在重置退避计数前,记住本次是否是重连(用于后续检测服务端残留session)
+ final bool wasReconnecting = _reconnectAttempt > 0;
+ _reconnectAttempt = 0; // 🔥 连接成功,重置重连退避
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.complete();
debugPrint('✅ [TCP-connect] 连接就绪,通知等待方');
@@ -225,6 +241,8 @@ class TcpClient {
// 开始认证tcp
await _sendAuthPacket();
+ // 🔥 启动心跳超时检测:等待服务端心跳,超时则触发重连
+ startHeartbeat();
_socket!.listen(
(data) {
//debugPrint('📥 [TCP] 收到原始数据:${data.length} 字节, 内容:$data');
@@ -248,12 +266,23 @@ class TcpClient {
shouldLog: false,
);
for (var packet in packets) {
- // 🔥 最根部日志:收到任何推送都打印
- // debugPrint('📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}');
- // _logger.logWithLevel(
- // '📥 [TCP-ROOT] 收到推送 CMD: 0x${packet.command.toRadixString(16).toUpperCase()}, Payload长度: ${packet.payload.length}',
- // shouldLog: true,
- // );
+ // 🔥 关键修复:重连期间收到 have_logged_in,拦截不分发给 AuthCubit
+ // 必须在 _controller.add 之前检查,否则 AuthCubit 会判定为异地登录弹出退出弹窗
+ if (packet.command == 0x12 && wasReconnecting) {
+ try {
+ final payloadStr = utf8.decode(packet.payload.length > 2
+ ? packet.payload.sublist(0, packet.payload.length - 2)
+ : packet.payload);
+ if (payloadStr.contains('have_logged_in')) {
+ debugPrint('🔄 [TCP] 重连后收到 have_logged_in,服务端session残留!立即断开让服务端清理session');
+ _logger.logWithLevel('🔄 [TCP] 重连后收到 have_logged_in,服务端session残留,强制重连', shouldLog: true);
+ _handleReconnectSessionReset(myConnId);
+ return; // 停止处理后续数据,不分发给任何监听器
+ }
+ } catch (_) {
+ // 解析失败不影响主流程
+ }
+ }
if (!_controller.isClosed) {
_controller.add(packet);
@@ -264,12 +293,9 @@ class TcpClient {
);
}
if (packet.command == 0xFF) {
- //debugPrint('收到服务端心跳,自动回复...');
- _logger.logWithLevel(
- '✅ [TCP] 收到服务端心跳,自动回复...',
- shouldLog: false,
- );
+ debugPrint('📥 [TCP] ✅ 收到服务端心跳 0xFF,重置超时计时器');
sendHeartbeat(); // 回复 AB AA FF AA AB
+ _resetHeartbeatTimeout(); // 重置超时计时器
}
if (packet.command == 0x03) {
// debugPrint('⚠️ 收到认证响应:${packet.payload}');
@@ -289,24 +315,28 @@ class TcpClient {
}
},
onDone: () {
- // TODO: 断线重连
- // debugPrint('来到断线重连!');
+ // 🔥 关键修复:检查 connectionId,防止旧socket的回调污染新连接
+ if (myConnId != _connectionId) {
+ debugPrint('⏭️ [TCP-onDone] 旧连接 #$myConnId 的回调,当前 #$_connectionId,忽略');
+ _logger.logWithLevel('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,忽略', shouldLog: true);
+ return;
+ }
if (!_isSwitching) {
- //debugPrint('onDone❌ [TCP] 连接已断开!');
- _logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: false);
+ _logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: true);
_handleDisconnect();
}
- _isSwitching = false; // 重置标志,以免影响下次
+ _isSwitching = false;
},
onError: (e) {
- //debugPrint('来到断线重连!error');
+ if (myConnId != _connectionId) {
+ debugPrint('⏭️ [TCP-onError] 旧连接 #$myConnId 的回调,当前 #$_connectionId,忽略');
+ return;
+ }
_logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
if (!_isSwitching) {
- //debugPrint('❌ [TCP] 发生错误:$e');
- _logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
_handleDisconnect();
- } // 统一走重连逻辑,保护 Controller 不被关闭
- _isSwitching = false; // 重置标志,以免影响下次
+ }
+ _isSwitching = false;
},
);
} catch (e) {
@@ -318,13 +348,46 @@ class TcpClient {
}
}
+ /// 🔥 重连后服务端session残留处理:立即销毁连接,等待服务端清理session后再重连
+ /// 这是解决"退出登录重连就好"问题的核心:
+ /// 退出登录 → forceDisconnect → 服务端有足够时间清理session → 重新登录 → 新session → 心跳正常
+ /// 自动重连 → 5秒内重连 → 服务端session还在 → have_logged_in → 不重启心跳 → 超时循环
+ void _handleReconnectSessionReset(int myConnId) {
+ _connectionId++; // 使旧socket回调失效
+ stopHeartbeat();
+ if (_socket != null) {
+ _socket!.destroy();
+ _socket = null;
+ }
+ // 🔥 服务端session残留也算一次重连失败
+ _reconnectFailCount++;
+ debugPrint('📊 [TCP] session残留,重连失败计数: $_reconnectFailCount/4');
+ if (_reconnectFailCount >= 4) {
+ debugPrint('🛑 [TCP] 重连失败已达 4 次,停止重连,通知用户退出登录');
+ _logger.logWithLevel('🛑 [TCP] 重连失败已达 4 次,停止重连', shouldLog: true);
+ onReconnectExhausted?.call();
+ return;
+ }
+ // 🔥 递增重连尝试次数,让下次重连等待更长时间
+ _reconnectAttempt++;
+ _scheduleReconnect();
+ }
+
//
void _handleDisconnect() {
+ _connectionId++; // 🔥 使旧socket的所有回调立即失效,防止onDone/onError污染新连接
+ debugPrint('🔌 [TCP] _handleDisconnect, connectionId→$_connectionId');
+ _logger.logWithLevel('🔌 [TCP] _handleDisconnect, connectionId→$_connectionId', shouldLog: true);
stopHeartbeat();
_socket = null;
- // 注意:这里不要关闭 _controller!否则监听者会丢失数据流
- // _controller?.close();
+ // 🔥 连续失败达到4次,停止重连,通知上层弹窗
+ if (_reconnectFailCount >= 4) {
+ debugPrint('🛑 [TCP] 重连失败已达 4 次,停止重连,通知用户退出登录');
+ _logger.logWithLevel('🛑 [TCP] 重连失败已达 4 次,停止重连', shouldLog: true);
+ onReconnectExhausted?.call();
+ return;
+ }
_scheduleReconnect();
}
@@ -333,15 +396,16 @@ class TcpClient {
if (isUserSwitch) return;
if (_reconnectTimer != null) return;
- // debugPrint('⏳ 调度重连:Host=${_lastHost}, Port=${_lastPort}'); // ✅ 检查 Host/Port 是否为空
- _logger.logWithLevel('⏳ 调度重连:Host=${_lastHost}, Port=${_lastPort}');
+ // 🔥 重连退避:5s → 10s → 20s → 30s(max)
+ final delay = [5, 10, 20, 30][_reconnectAttempt.clamp(0, 3)];
+ _reconnectAttempt++;
+ _logger.logWithLevel('⏳ 调度重连:第$_reconnectAttempt次,${delay}s后重连,Host=${_lastHost}, Port=${_lastPort}');
if (_lastHost == null || _lastPort == null) {
- //debugPrint('❌ 无法重连:Host 或 Port 为空!');
_logger.logWithLevel('❌ 无法重连:Host 或 Port 为空!', shouldLog: true);
return;
}
- _reconnectTimer = Timer(const Duration(seconds: 5), () {
+ _reconnectTimer = Timer(Duration(seconds: delay), () async {
_reconnectTimer = null;
// 🔥 关键修复:定时器触发时检查是否已有连接,避免重复创建
if (_socket != null) {
@@ -355,7 +419,29 @@ class TcpClient {
return;
}
_logger.logWithLevel('⏰ 定时器触发,开始执行重连...', shouldLog: true);
- connect(host: _lastHost!, port: _lastPort!);
+ try {
+ await connect(host: _lastHost!, port: _lastPort!);
+ _reconnectAttempt = 0; // 🔥 重连成功,重置退避计数
+ // 🔥 重连成功后,补调 HTTP switchDevice 告知服务端推送目标设备
+ try {
+ final remoteControlCubit = GetIt.I();
+ final targetDevice = remoteControlCubit.state.targetDevice;
+ if (targetDevice != null) {
+ debugPrint('🔄 [TCP-重连] 补调 HTTP switchDevice: ${targetDevice.deviceName}');
+ _logger.logWithLevel('🔄 [TCP-重连] 补调 HTTP switchDevice: ${targetDevice.deviceName}', shouldLog: true);
+ await switchDeviceUseCase.deviceRepository.switchDevice('app', targetDevice.deviceName);
+ debugPrint('✅ [TCP-重连] HTTP switchDevice 成功');
+ } else {
+ debugPrint('⚠️ [TCP-重连] targetDevice 为 null,跳过 switchDevice');
+ }
+ } catch (e) {
+ debugPrint('❌ [TCP-重连] HTTP switchDevice 失败: $e');
+ _logger.logWithLevel('❌ [TCP-重连] HTTP switchDevice 失败: $e', shouldLog: true);
+ }
+ } catch (e) {
+ debugPrint('❌ [TCP-重连] connect 失败: $e');
+ _logger.logWithLevel('❌ [TCP-重连] connect 失败: $e', shouldLog: true);
+ }
});
}
@@ -446,19 +532,47 @@ class TcpClient {
..addByte(0xAA)
..addByte(0xAB);
_socket!.add(builder.takeBytes());
+ debugPrint('💓 [TCP] 发送心跳 AB AA FF AA AB');
}
- // 新增:启动心跳(每 4 秒发送一次 0xFF 指令)
+ /// 启动心跳:主动定时发送 + 超时检测
+ /// 1. 每5秒主动给服务端发心跳,防止服务端判定客户端失效
+ /// 2. 15秒内没收到服务端心跳,判定连接断开并重连
void startHeartbeat({Duration interval = const Duration(seconds: 4)}) {
if (_heartbeatTimer != null) return; // 防止重复启动
- debugPrint('⏰ [TCP] 启动心跳定时器,间隔: ${interval.inSeconds}秒');
- _logger.logWithLevel('✅ [TCP] 收到服务端心跳,自动回复...', shouldLog: true);
- _heartbeatTimer = Timer.periodic(interval, (_) {
- // 发送心跳帧:AB AA FF AA AB
- debugPrint('💓 [TCP] 发送心跳包 0xFF');
- sendHeartbeat();
+ debugPrint('⏰ [TCP] 启动心跳:主动发送(5s) + 超时检测(${_heartbeatTimeout.inSeconds}s)');
+ _logger.logWithLevel('⏰ [TCP] 启动心跳:主动发送(5s) + 超时检测(${_heartbeatTimeout.inSeconds}s)', shouldLog: true);
+
+ // 🔥 主动定时发送心跳给服务端(每5秒)
+ _heartbeatSendTimer?.cancel();
+ _heartbeatSendTimer = Timer.periodic(const Duration(seconds: 5), (_) {
+ if (_socket != null) {
+ sendHeartbeat();
+ }
+ });
+
+ // 超时检测
+ _resetHeartbeatTimeout();
+ }
+
+ /// 重置心跳超时定时器
+ /// 每次收到服务端心跳(0xFF)时调用,重新开始计时
+ void _resetHeartbeatTimeout() {
+ _heartbeatTimer?.cancel();
+ _heartbeatTimer = Timer(_heartbeatTimeout, () {
+ debugPrint('⚠️ [TCP] 心跳超时 ${_heartbeatTimeout.inSeconds}s 未收到服务端心跳,判定连接断开');
+ _logger.logWithLevel('⚠️ [TCP] 心跳超时,判定连接断开', shouldLog: true);
+ // 触发断开重连
+ if (_socket != null) {
+ _socket!.destroy();
+ _socket = null;
+ }
+ // 🔥 累计重连失败次数(心跳超时 = 本次连接无效 = 一次失败)
+ _reconnectFailCount++;
+ debugPrint('📊 [TCP] 重连失败计数: $_reconnectFailCount/4');
+ // 🔥 统一走普通断开重连路径(_scheduleReconnect 已包含 HTTP switchDevice)
+ _handleDisconnect();
});
- debugPrint("✅ TCP Connected - 心跳已启动");
}
/// 🔥 封装完整的TCP初始化方法:连接 + 认证 + 心跳
@@ -478,6 +592,8 @@ class TcpClient {
_connecting = true;
_connectionCompleter = Completer();
+ _connectionId++; // 🔥 新一代连接,旧socket回调自动失效
+ final myConnId = _connectionId;
try {
// 更新状态为连接中
@@ -516,7 +632,7 @@ class TcpClient {
_logger.logWithLevel('✅ [TCP] 心跳已启动', shouldLog: true);
// 设置数据监听(复用现有的监听逻辑)
- _setupDataListener();
+ _setupDataListener(myConnId);
_connecting = false;
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
@@ -538,7 +654,7 @@ class TcpClient {
}
/// 设置数据监听器
- void _setupDataListener() {
+ void _setupDataListener(int myConnId) {
if (_socket == null) return;
_socket!.listen(
@@ -557,7 +673,9 @@ class TcpClient {
_controller.add(packet);
}
if (packet.command == 0xFF) {
+ debugPrint('📥 [TCP] ✅ 收到服务端心跳 0xFF,重置超时计时器');
sendHeartbeat();
+ _resetHeartbeatTimeout(); // 重置超时计时器
}
if (packet.command == 0x03) {
_logger.logWithLevel(
@@ -574,13 +692,23 @@ class TcpClient {
}
},
onDone: () {
+ // 🔥 检查 connectionId,防止旧socket回调污染新连接
+ if (myConnId != _connectionId) {
+ debugPrint('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
+ _logger.logWithLevel('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,忽略', shouldLog: true);
+ return;
+ }
if (!_isSwitching) {
- _logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: false);
+ _logger.logWithLevel('❌ [TCP] 连接已断开!', shouldLog: true);
_handleDisconnect();
}
_isSwitching = false;
},
onError: (e) {
+ if (myConnId != _connectionId) {
+ debugPrint('⏭️ [TCP-onError] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
+ return;
+ }
_logger.logWithLevel('❌ [TCP] 发生错误:$e', shouldLog: true);
if (!_isSwitching) {
_handleDisconnect();
@@ -590,10 +718,12 @@ class TcpClient {
);
}
- // 新增:停止心跳
+ // 停止心跳(主动发送 + 超时检测全部停止)
void stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
+ _heartbeatSendTimer?.cancel();
+ _heartbeatSendTimer = null;
}
// void disconnect() {
@@ -608,7 +738,8 @@ class TcpClient {
//
// }
void disconnect() {
- debugPrint('🛑 [TCP] 主动断开连接...');
+ _connectionId++; // 🔥 使旧socket回调失效
+ debugPrint('🛑 [TCP] 主动断开连接... connectionId→$_connectionId');
_logger.logWithLevel('🛑 [TCP] 主动断开连接...');
// 1. 停止心跳
@@ -629,8 +760,8 @@ class TcpClient {
_isSwitching = false;
_connecting = false;
_connectionCompleter = null;
-
- // 5. 销毁 Socket
+ _reconnectAttempt = 0; // 🔥 重置重连退避
+ _reconnectFailCount = 0; // 🔥 重置重连失败计数
if (_socket != null) {
_socket!.destroy();
_socket = null;
@@ -736,7 +867,7 @@ class TcpClient {
_lastAuthPacketSentAt = DateTime.now(); // 🔥 记录发送 0x03 的时间
_socket!.add(builder.takeBytes());
- //debugPrint('🔑 [TCP] 已发送认证包 (0x03): $authString');
+ await _socket!.flush(); // 🔥 关键修复:强制 flush,确保认证包立即到达服务端
_logger.logWithLevel('🔑 [TCP] 已发送认证包 (0x03): $authString');
// 🔥 关键修复:移除登录时自动获取设备列表和切换设备的逻辑
@@ -850,7 +981,9 @@ class TcpClient {
isUserSwitch = true;
_connecting = true; // 🔥 防止 connect() 并发创建新连接
- debugPrint('🔌 [TCP-connectBySwitch] 无现有连接,开始新建');
+ _connectionId++; // 🔥 新一代连接,旧socket回调自动失效
+ final myConnId = _connectionId;
+ debugPrint('🔌 [TCP-connectBySwitch] 无现有连接,开始新建, connectionId=$myConnId');
// debugPrint('🔌被动 [TCP] 开始连接:$host:$port'); // ✅ 必须看到这条
_logger.logWithLevel('🔌被动 [TCP] 开始连接:$host:$port');
@@ -908,10 +1041,9 @@ class TcpClient {
);
}
if (packet.command == 0xFF) {
- //debugPrint('收到服务端心跳,自动回复...');
- _logger.logWithLevel('✅ [TCP] 收到服务端心跳,自动回复...');
- //
+ debugPrint('📥 [TCP] ✅ 收到服务端心跳 0xFF,重置超时计时器');
sendHeartbeat(); // 回复 AB AA FF AA AB
+ _resetHeartbeatTimeout(); // 重置超时计时器
}
if (packet.command == 0x03) {
//debugPrint('⚠️ 收到认证响应:${packet.payload}');
@@ -925,18 +1057,28 @@ class TcpClient {
}
},
onDone: () {
+ // 🔥 检查 connectionId,防止旧socket回调污染新连接
+ if (myConnId != _connectionId) {
+ debugPrint('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
+ _logger.logWithLevel('⏭️ [TCP-onDone] 旧连接 #$myConnId 回调,忽略', shouldLog: true);
+ return;
+ }
debugPrint('onDone [TCP] 被动连接已断开!_isSwitching=$_isSwitching');
if (!_isSwitching) {
- _handleDisconnectBySwitch(deviceName);
+ _handleDisconnect();
} else {
debugPrint('🚫 [TCP] 切换过程中的断开,忽略重连调度');
_isSwitching = false;
}
},
onError: (e) {
+ if (myConnId != _connectionId) {
+ debugPrint('⏭️ [TCP-onError] 旧连接 #$myConnId 回调,当前 #$_connectionId,忽略');
+ return;
+ }
_logger.logWithLevel('❌ 被动[TCP] 发生错误:$e');
if (!_isSwitching) {
- _handleDisconnectBySwitch(deviceName);
+ _handleDisconnect();
} else {
debugPrint('🚫 [TCP] 切换过程中的错误,忽略重连调度');
_isSwitching = false;
@@ -951,8 +1093,9 @@ class TcpClient {
rethrow; // 向上抛出连接异常
}
- // 🔥 关键修复:只有在所有操作成功后才重置 _connecting
+ // 🔥 重置 _connecting 和 isUserSwitch
_connecting = false;
+ isUserSwitch = false; // 🔥 关键:切换完成后重置,后续超时走普通重连路径
if (_connectionCompleter != null && !_connectionCompleter!.isCompleted) {
_connectionCompleter!.complete();
debugPrint('✅ [TCP-connectBySwitch] 连接就绪,通知等待方');
@@ -1029,6 +1172,9 @@ class TcpClient {
}
void _handleDisconnectBySwitch(String deviceName) {
+ _connectionId++; // 🔥 使旧socket的所有回调立即失效
+ debugPrint('🔌 [TCP] _handleDisconnectBySwitch, connectionId→$_connectionId');
+ _logger.logWithLevel('🔌 [TCP] _handleDisconnectBySwitch, connectionId→$_connectionId', shouldLog: true);
stopHeartbeat();
_socket = null;
diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart
index 656a4269..fa500c85 100644
--- a/lib/core/router/app_router.dart
+++ b/lib/core/router/app_router.dart
@@ -9,6 +9,7 @@ import 'package:maibu_satabot_v2/features/home/presentation/routes/home_routes.d
import 'package:maibu_satabot_v2/features/main_container/presentation/pages/tab_settings_page.dart';
import 'package:maibu_satabot_v2/features/my/presentation/routes/my_routes.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/routes/alarm_center_routes.dart';
+import 'package:maibu_satabot_v2/features/v2/workorder/presentation/routes/workorder_routes.dart';
import '../../features/auth/presentation/bloc/auth_cubit.dart';
import '../../features/auth/presentation/bloc/auth_state.dart';
@@ -20,11 +21,13 @@ import 'go_router_refresh_stream.dart';
GoRouter createRouter(AuthCubit authCubit) {
// 调试:打印所有路由
debugPrint('🔍 [Router] 开始创建路由配置...');
- debugPrint('🔍 [Router] AlarmCenterRoutes.routes 数量: ${AlarmCenterRoutes.routes.length}');
+ debugPrint(
+ '🔍 [Router] AlarmCenterRoutes.routes 数量: ${AlarmCenterRoutes.routes.length}',
+ );
for (var route in AlarmCenterRoutes.routes) {
debugPrint('🔍 [Router] 路由: ${route.toString()}');
}
-
+
return GoRouter(
navigatorKey: navigatorKey, // 🔥 绑定全局 navigatorKey,使异地登录弹窗能获取 context
initialLocation: RoutePaths.login,
@@ -74,6 +77,7 @@ GoRouter createRouter(AuthCubit authCubit) {
...AiRoutes.routes,
...MyRoutes.routes,
...AlarmCenterRoutes.routes,
+ ...WorkOrderRoutes.routes,
],
);
}
diff --git a/lib/core/router/route_paths.dart b/lib/core/router/route_paths.dart
index 981020f1..32970de6 100644
--- a/lib/core/router/route_paths.dart
+++ b/lib/core/router/route_paths.dart
@@ -20,4 +20,8 @@ class RoutePaths {
static const settings = '/my/settings';
static const machineDetails = '/machine_details/machine_details';
+
+ /// 工单相关页面
+ static const workOrder = '/workorder';
+ static const workOrderDetail = '/workorder/detail/:orderId';
}
diff --git a/lib/core/services/device_permission_service.dart b/lib/core/services/device_permission_service.dart
new file mode 100644
index 00000000..a00436aa
--- /dev/null
+++ b/lib/core/services/device_permission_service.dart
@@ -0,0 +1,69 @@
+import 'package:dio/dio.dart';
+import 'package:flutter/foundation.dart';
+import '../consts/http_api_consts.dart';
+
+/// 设备操作权限校验服务
+/// 接口: GET /iot/device/hasPermission?deviceId=xxx
+/// 通过条件: code==200 且 data==true,缺一不可
+/// 安全原则: 仅明确收到 code=200 && data=true 才放行,其余所有情况一律阻止
+class DevicePermissionService {
+ final Dio _dio;
+
+ DevicePermissionService(this._dio);
+
+ /// 校验当前用户是否有权操作指定设备
+ /// 返回 true 表示有权限,false 表示无权限或校验失败
+ Future checkPermission(String deviceId) async {
+ debugPrint('🔐 [权限校验] 开始校验 - deviceId: $deviceId');
+
+ try {
+ final response = await _dio.get(
+ HttpApiConsts.hasPermission,
+ queryParameters: {'deviceId': deviceId},
+ );
+
+ debugPrint('🔐 [权限校验] HTTP响应 - statusCode: ${response.statusCode}');
+
+ // HTTP 层面必须 200
+ if (response.statusCode != 200) {
+ debugPrint('🔐 [权限校验] ❌ HTTP状态码非200: ${response.statusCode}');
+ return false;
+ }
+
+ final body = response.data;
+ debugPrint('🔐 [权限校验] 响应体: $body');
+
+ // body 必须是 Map
+ if (body is! Map) {
+ debugPrint('🔐 [权限校验] ❌ 响应体格式异常,非Map类型');
+ return false;
+ }
+
+ final code = body['code'];
+ final data = body['data'];
+
+ debugPrint('🔐 [权限校验] code=$code (type: ${code.runtimeType}), data=$data (type: ${data.runtimeType})');
+
+ // code 必须是 200(兼容 int 和 String)
+ final codeMatch = code == 200 || code.toString() == '200';
+ // data 必须是 true
+ final dataMatch = data == true || data.toString() == 'true';
+
+ if (codeMatch && dataMatch) {
+ debugPrint('🔐 [权限校验] ✅ 校验通过 - code=200, data=true');
+ return true;
+ }
+
+ debugPrint('🔐 [权限校验] ❌ 校验未通过 - codeMatch=$codeMatch, dataMatch=$dataMatch');
+ return false;
+ } on DioException catch (e) {
+ // 网络异常(断网、超时、服务器不可达等)
+ debugPrint('🔐 [权限校验] ❌ DioException: ${e.type} - ${e.message}');
+ return false;
+ } catch (e) {
+ // 任何未知异常
+ debugPrint('🔐 [权限校验] ❌ 未知异常: $e');
+ return false;
+ }
+ }
+}
diff --git a/lib/features/auth/data/models/user_model.dart b/lib/features/auth/data/models/user_model.dart
index 491572b5..ae014c52 100644
--- a/lib/features/auth/data/models/user_model.dart
+++ b/lib/features/auth/data/models/user_model.dart
@@ -12,19 +12,22 @@ class UserModel extends UserEntity implements BaseModel {
super.avatar,
super.email,
super.phone,
+ super.roleKey,
+ super.siteId,
});
factory UserModel.fromJson(Map json) {
- print(json);
return UserModel(
- userId: json['userId'],
- username: json['username'],
- nickname: json['nickName'],
- token: json['token'],
- orgId: json['orgId'] ?? 0, // 从登录响应中获取 orgId
+ userId: json['userId']?.toString() ?? '',
+ username: json['username'] ?? '',
+ nickname: json['nickName'] ?? '',
+ token: json['token'] ?? '',
+ orgId: (json['orgId'] as num?)?.toInt() ?? 0,
avatar: json['avatar'],
email: json['email'],
phone: json['phone'],
+ roleKey: json['roleKey'],
+ siteId: (json['siteId'] as num?)?.toInt(),
);
}
@@ -38,6 +41,8 @@ class UserModel extends UserEntity implements BaseModel {
'avatar': avatar,
'email': email,
'phone': phone,
+ 'roleKey': roleKey,
+ 'siteId': siteId,
};
}
@@ -51,6 +56,8 @@ class UserModel extends UserEntity implements BaseModel {
avatar: avatar,
email: email,
phone: phone,
+ roleKey: roleKey,
+ siteId: siteId,
);
}
@@ -64,6 +71,8 @@ class UserModel extends UserEntity implements BaseModel {
avatar: entity.avatar,
email: entity.email,
phone: entity.phone,
+ roleKey: entity.roleKey,
+ siteId: entity.siteId,
);
}
-}
+}
\ No newline at end of file
diff --git a/lib/features/auth/presentation/bloc/auth_cubit.dart b/lib/features/auth/presentation/bloc/auth_cubit.dart
index 39c3213b..08c885bf 100644
--- a/lib/features/auth/presentation/bloc/auth_cubit.dart
+++ b/lib/features/auth/presentation/bloc/auth_cubit.dart
@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:convert';
+import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
+import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
@@ -20,6 +23,11 @@ import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../../../devices/presentation/bloc/device_status_bloc.dart';
import '../../../devices/presentation/bloc/device_status_event.dart';
import '../../../devices/presentation/bloc/devices_state.dart';
+import '../../../devices/presentation/bloc/device_task_cubit.dart';
+import '../../../home/presentation/bloc/permission_request_bloc.dart';
+import '../../../my/presentation/bloc/my_cubit.dart';
+import '../../../remote_control/presentation/bloc/remote_control_cubit.dart';
+import '../../../../core/network/mqtt/domain/interfaces/mqtt_client.dart';
import '../../../../features/v2/site/presentation/cubit/site_cubit.dart';
import '../../data/datasources/auth_tcp_datasource.dart';
import '../../data/datasources/impl/auth_tcp_datasource_impl.dart';
@@ -39,7 +47,7 @@ class AuthCubit extends Cubit {
final ILoggerService _logger = GetIt.I();
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
-
+
// 🔥 登录验证 Completer:用于等待登录阶段的 have_logged_in 推送
Completer? _loginVerificationCompleter;
@@ -56,11 +64,57 @@ class AuthCubit extends Cubit {
) : super(AuthInitial()) {
// Cubit 一启动就开始监听 TCP 的“自动逻辑”
_listenToAuthResponse();
+ // 🔥 设置重连耗尽回调:连续4次重连失败后弹窗提示用户退出登录
+ tcp.onReconnectExhausted = _showReconnectFailedDialog;
}
/// App 启动时检查本地缓存
Future appStarted() async {
final logger = GetIt.I() as SentryLoggerImpl;
+
+ try {
+ final prefs = GetIt.I();
+
+ // Step 1: 先生成新的 session_id,保存旧的
+ final oldSessionId = prefs.getString('current_session_id');
+ final newSessionId = DateTime.now().millisecondsSinceEpoch.toString();
+ await prefs.setString('current_session_id', newSessionId);
+ debugPrint('📱 [AUTH] 新会话: $newSessionId,旧会话: $oldSessionId');
+
+ // Step 2: 检查是否从后台被杀
+ final pendingKillLogout = prefs.getBool('pending_kill_logout') ?? false;
+ final savedSessionId = prefs.getString('saved_session_id');
+
+ if (pendingKillLogout && savedSessionId != null && oldSessionId != null) {
+ if (savedSessionId == oldSessionId) {
+ // saved == old_current → App 在后台被杀,从未恢复过
+ logger.logWithLevel('🔄 [AUTH] 检测到 APP 被后台杀死,执行退出登录', level: 'INFO');
+ await prefs.setBool('pending_kill_logout', false);
+ await prefs.remove('saved_session_id');
+ await storage.deleteUser();
+ emit(AuthUnauthenticated());
+ return;
+ } else {
+ // saved != old_current → App 被杀前已恢复过,清除标记
+ logger.logWithLevel(
+ '📱 [AUTH] pending_kill_logout=true 但会话已恢复过,清除标记',
+ level: 'INFO',
+ );
+ await prefs.setBool('pending_kill_logout', false);
+ await prefs.remove('saved_session_id');
+ }
+ } else if (pendingKillLogout) {
+ logger.logWithLevel(
+ '📱 [AUTH] pending_kill_logout=true 但无会话信息,清除标记',
+ level: 'INFO',
+ );
+ await prefs.setBool('pending_kill_logout', false);
+ await prefs.remove('saved_session_id');
+ }
+ } catch (e) {
+ logger.logWithLevel('❌ [AUTH] 检查杀后台标记失败: $e', level: 'ERROR');
+ }
+
try {
final user = await storage.getUser();
logger.logWithLevel(
@@ -70,14 +124,21 @@ class AuthCubit extends Cubit {
);
if (user != null) {
- // 🔥 冷启动时不创建TCP连接,改为用户选择设备时再连接
- // 避免发送 0x03 触发服务端残留 session 的 have_logged_in
-
- // 2. 同步全局 App 状态
- appCubit.setAuth(user);
- // 3. 进入已登录状态
- emit(AuthAuthenticated(user));
- logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
+ final isValid = await _verifyToken(user);
+ if (isValid) {
+ logger.logWithLevel('✅ [AUTH] Token 有效,恢复登录状态', level: 'INFO');
+ appCubit.setAuth(user);
+ emit(AuthAuthenticated(user));
+ logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
+ } else {
+ logger.logWithLevel('⚠️ [AUTH] Token 已过期,清除本地缓存', level: 'WARN');
+ await storage.deleteUser();
+ emit(AuthUnauthenticated());
+ logger.logWithLevel(
+ '⚠️ [AUTH] 应用启动 - Token 过期,进入未登录状态',
+ level: 'INFO',
+ );
+ }
} else {
logger.logWithLevel('⚠️ [AUTH] 应用启动 - 无本地缓存,进入未登录状态', level: 'INFO');
emit(AuthUnauthenticated());
@@ -88,6 +149,42 @@ class AuthCubit extends Cubit {
}
}
+ Future _verifyToken(UserEntity user) async {
+ try {
+ final verifyDio = Dio(
+ BaseOptions(
+ baseUrl: HttpApiConsts.baseUrl,
+ headers: {'Authorization': 'Bearer ${user.token}'},
+ connectTimeout: const Duration(seconds: 5),
+ receiveTimeout: const Duration(seconds: 5),
+ ),
+ );
+ final response = await verifyDio.get(
+ HttpApiConsts.getUserDevicesList,
+ queryParameters: {'tenantName': user.username},
+ );
+
+ if (response.statusCode == 200) {
+ final data = response.data;
+ if (data is Map && data['code'] == 401) {
+ return false;
+ }
+ return true;
+ }
+ return false;
+ } on DioException catch (e) {
+ final statusCode = e.response?.statusCode;
+ debugPrint('>>> [AUTH] Token 验证失败: HTTP $statusCode');
+ if (statusCode == 401 || statusCode == 403) {
+ return false;
+ }
+ return true;
+ } catch (e) {
+ debugPrint('>>> [AUTH] Token 验证异常: $e');
+ return true;
+ }
+ }
+
/// 当 HTTP 登录/注册成功后调用
Future loginSuccess(UserEntity user) async {
await storage.saveUser(user);
@@ -96,33 +193,33 @@ class AuthCubit extends Cubit {
try {
debugPrint('>>> [AUTH] 登录成功,开始建立TCP连接...');
_logger.logWithLevel('>>> [AUTH] 登录成功,开始建立TCP连接...', shouldLog: true);
-
+
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
tcp.startHeartbeat(interval: const Duration(seconds: 4));
-
+
// 🔥 关键:等待 2.5 秒,看是否收到 have_logged_in
bool isKicked = await _waitForLoginVerification();
-
+
if (isKicked) {
// 🔥 不能进入 APP,必须清除所有登录信息
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息...');
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息', shouldLog: true);
-
+
// 1. 删除已保存的用户信息
await storage.deleteUser();
debugPrint('✅ [AUTH] 已删除用户信息');
-
+
// 2. 断开 TCP 连接
tcp.forceDisconnect();
debugPrint('✅ [AUTH] 已断开 TCP 连接');
-
+
// 3. 显示 Toast
_showLoginFailedToast("账号已在其他设备登录");
debugPrint('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页');
_logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页', shouldLog: true);
- return; // 停留在登录页
+ return; // 停留在登录页
}
-
+
debugPrint('✅ [AUTH] TCP连接成功,验证通过');
_logger.logWithLevel('✅ [AUTH] TCP连接成功,验证通过', shouldLog: true);
} catch (e) {
@@ -149,6 +246,42 @@ class AuthCubit extends Cubit {
emit(AuthUnauthenticated());
}
+ /// 🔥 Token 过期处理:弹出提示后退出登录
+ Future tokenExpired() async {
+ _showTokenExpiredDialog();
+ Future.delayed(const Duration(seconds: 2), () {
+ logout();
+ });
+ }
+
+ void _showTokenExpiredDialog() {
+ try {
+ final context = navigatorKey.currentContext;
+ if (context != null) {
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext dialogContext) {
+ return AlertDialog(
+ title: const Text('登录已过期'),
+ content: const Text('账号登录状态已过期,请重新登录'),
+ actions: [
+ ElevatedButton(
+ onPressed: () {
+ Navigator.of(dialogContext).pop();
+ },
+ child: const Text('确定'),
+ ),
+ ],
+ );
+ },
+ );
+ }
+ } catch (e) {
+ debugPrint('>>> [AUTH] ❌ 显示 Token 过期弹窗失败:$e');
+ }
+ }
+
/// 🔥 新增:显示登录失败 Toast(黑色背景,和登录错误提示一致)
void _showLoginFailedToast(String message) {
try {
@@ -173,7 +306,7 @@ class AuthCubit extends Cubit {
try {
debugPrint('>>> [AUTH] 📢 准备显示异地登录提示弹窗');
_logger.logWithLevel('>>> [AUTH] 📢 准备显示异地登录提示弹窗', shouldLog: true);
-
+
// 使用全局 navigatorKey 显示弹窗
final context = navigatorKey.currentContext;
if (context != null) {
@@ -190,9 +323,12 @@ class AuthCubit extends Cubit {
);
} else {
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出');
- _logger.logWithLevel('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出', shouldLog: true);
+ _logger.logWithLevel(
+ '>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出',
+ shouldLog: true,
+ );
}
-
+
// 🔥 延迟 2 秒后执行退出,给用户时间看到提示
Future.delayed(const Duration(seconds: 2), () {
debugPrint('>>> [AUTH] ⏰ 延迟结束,开始执行退出登录');
@@ -207,27 +343,142 @@ class AuthCubit extends Cubit {
}
}
+ /// 🔥 TCP 重连耗尽弹窗:连续4次重连失败后提示用户退出登录
+ void _showReconnectFailedDialog() {
+ try {
+ debugPrint('>>> [AUTH] 📢 重连耗尽,准备显示重连失败提示弹窗');
+ _logger.logWithLevel('>>> [AUTH] 📢 重连耗尽,显示重连失败弹窗', shouldLog: true);
+
+ final context = navigatorKey.currentContext;
+ if (context != null) {
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (BuildContext dialogContext) {
+ return AlertDialog(
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.wifi_off_rounded, size: 48, color: Color(0xFFF53F3F)),
+ const SizedBox(height: 12),
+ const Text(
+ '连接异常',
+ style: TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ color: Color(0xFF1D2129),
+ ),
+ ),
+ const SizedBox(height: 8),
+ const Text(
+ 'TCP重连认证无效请重新登陆',
+ style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ actions: [
+ SizedBox(
+ width: double.infinity,
+ child: ElevatedButton(
+ onPressed: () {
+ Navigator.of(dialogContext).pop();
+ logout();
+ },
+ style: ElevatedButton.styleFrom(
+ backgroundColor: const Color(0xFF165DFF),
+ foregroundColor: Colors.white,
+ elevation: 0,
+ padding: const EdgeInsets.symmetric(vertical: 12),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ ),
+ child: const Text('退出登录', style: TextStyle(fontSize: 15)),
+ ),
+ ),
+ ],
+ );
+ },
+ );
+ } else {
+ debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,直接退出登录');
+ logout();
+ }
+ } catch (e) {
+ debugPrint('>>> [AUTH] ❌ 显示重连失败弹窗失败:$e');
+ logout();
+ }
+ }
+
/// 🔥 新增:清空所有业务状态,防止数据泄露到新账户
void _clearAllBusinessState() {
try {
- final devicesCubit = GetIt.I();
- final deviceStatusBloc = GetIt.I();
- final siteCubit = GetIt.I();
+ // 1. 清空远程控制所有状态(targetDevice、权限、摇杆数据、电压电量等)
+ final remoteControlCubit = GetIt.I();
+ remoteControlCubit.clearAll();
+ debugPrint('✅ [AUTH] 已清空 RemoteControlCubit 状态');
+ _logger.logWithLevel('✅ [AUTH] 已清空 RemoteControlCubit 状态');
- // 1. 清空设备列表和选中设备
+ // 2. 清空设备列表和选中设备
+ final devicesCubit = GetIt.I();
devicesCubit.emit(const DevicesState());
debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态');
- // 2. 清空设备实时状态
+ // 3. 清空设备实时状态(图表数据等)
+ final deviceStatusBloc = GetIt.I();
deviceStatusBloc.add(DeviceStatusReset());
debugPrint('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
_logger.logWithLevel('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
- // 3. 清空全局选中的场站
- siteCubit.clearSelectedSite();
- debugPrint('✅ [AUTH] 已清空 SiteCubit 选中状态');
- _logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 选中状态');
+ // 4. 清空场站所有数据(列表、选中状态、_hasLoadedSites 标记)
+ final siteCubit = GetIt.I();
+ siteCubit.clearAll();
+ debugPrint('✅ [AUTH] 已清空 SiteCubit 所有数据');
+ _logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 所有数据');
+
+ // 5. 清空设备任务(taskPool、currentTask、currentTaskId 等)
+ final deviceTaskCubit = GetIt.I();
+ deviceTaskCubit.clearAll();
+ debugPrint('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
+ _logger.logWithLevel('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
+
+ // 6. 清空权限请求弹窗状态
+ final permissionRequestBloc = GetIt.I();
+ permissionRequestBloc.clearAll();
+ debugPrint('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
+ _logger.logWithLevel('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
+
+ // 7. 清空我的页面数据(昵称等个人信息)
+ final myCubit = GetIt.I();
+ myCubit.clearAll();
+ debugPrint('✅ [AUTH] 已清空 MyCubit 状态');
+ _logger.logWithLevel('✅ [AUTH] 已清空 MyCubit 状态');
+
+ // 8. 断开 MQTT 连接(避免新用户收到上个用户的实时推送)
+ try {
+ final droneOsdClient = GetIt.I(instanceName: 'droneOsdClient');
+ if (droneOsdClient.isConnected) {
+ droneOsdClient.disconnect();
+ debugPrint('✅ [AUTH] 已断开 droneOsdClient MQTT');
+ }
+ } catch (_) {}
+ try {
+ final taskMessageClient = GetIt.I(instanceName: 'taskMessageClient');
+ if (taskMessageClient.isConnected) {
+ taskMessageClient.disconnect();
+ debugPrint('✅ [AUTH] 已断开 taskMessageClient MQTT');
+ }
+ } catch (_) {}
+
+ // 9. 清除 SharedPreferences 会话相关 key
+ final prefs = GetIt.I();
+ prefs.remove('current_session_id');
+ prefs.remove('pending_kill_logout');
+ prefs.remove('saved_session_id');
+ debugPrint('✅ [AUTH] 已清除 SharedPreferences 会话 key');
debugPrint('✅ [AUTH] 所有业务状态已清空');
_logger.logWithLevel('✅ [AUTH] 所有业务状态已清空');
@@ -240,15 +491,15 @@ class AuthCubit extends Cubit {
/// 🔥 等待登录验证结果(2.5 秒内看是否收到 have_logged_in)
Future _waitForLoginVerification() async {
_loginVerificationCompleter = Completer();
-
+
// 等待 2.5 秒
await Future.delayed(const Duration(milliseconds: 2500));
-
+
// 如果 completer 还没完成,说明没收到 have_logged_in,返回 false(可以进入)
if (!_loginVerificationCompleter!.isCompleted) {
_loginVerificationCompleter!.complete(false);
}
-
+
return _loginVerificationCompleter!.future;
}
@@ -290,35 +541,46 @@ class AuthCubit extends Cubit {
if (respond == 'have_logged_in') {
// 🔥 登录阶段策略:HTTP 已成功,TCP 认证阶段的推送视为服务端状态同步,直接放行
- if (_loginVerificationCompleter != null && !_loginVerificationCompleter!.isCompleted) {
- debugPrint('>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP');
+ if (_loginVerificationCompleter != null &&
+ !_loginVerificationCompleter!.isCompleted) {
+ debugPrint(
+ '>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP',
+ );
_logger.logWithLevel(
'🛡️ [AUTH] 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入',
shouldLog: true,
);
- _loginVerificationCompleter!.complete(false); // 标记为可以进入
+ _loginVerificationCompleter!.complete(false); // 标记为可以进入
return;
}
-
+
// 🔥 已登录阶段策略:检查是否处于安全模式
if (_isSafeMode) {
print('>>> [AUTH] 🛡️ 安全模式下拦截 have_logged_in 推送,防止控制中断');
- _logger.logWithLevel(
- '🛡️ [AUTH] 安全模式下拦截异地登录推送',
- level: 'WARN',
- );
+ _logger.logWithLevel('🛡️ [AUTH] 安全模式下拦截异地登录推送', level: 'WARN');
return; // 拦截退出逻辑
}
-
+
print('>>> [AUTH] ⚠️ 已登录状态下收到 TCP 0x12 指令 respond=have_logged_in');
- debugPrint('>>> [AUTH] ℹ️ 暂时忽略该推送,观察是否影响业务操作...');
_logger.logWithLevel(
- '⚠️ [AUTH] 已登录状态收到 have_logged_in,暂不处理,观察业务影响',
+ '⚠️ [AUTH] 已登录状态收到 have_logged_in,弹出异地登录提示',
level: 'WARN',
);
-
- // 如果你希望依然保持严格的安全策略,可以取消下面注释恢复退出逻辑:
- // _showKickOutDialog();
+
+ // 🔥 关键:检查是否是自身 TCP 重连触发的 have_logged_in
+ // 如果是自身刚发送 0x03 认证包引起的,忽略这次推送
+ if (tcp.isOwnAuthTriggeredKick()) {
+ debugPrint('>>> [AUTH] 🛡️ 检测到是自身认证触发的 have_logged_in,忽略');
+ _logger.logWithLevel(
+ '🛡️ [AUTH] 自身认证触发的 have_logged_in,忽略',
+ level: 'INFO',
+ );
+ tcp.clearAuthTimestamp();
+ return;
+ }
+
+ // 🔥 弹出"账号被顶下线"提示,2秒后执行退出登录
+ _showKickOutDialog();
} else {
debugPrint('>>> [AUTH] ℹ️ 收到 0x12 消息,respond="$respond",不处理');
}
diff --git a/lib/features/auth/presentation/bloc/login_cubit.dart b/lib/features/auth/presentation/bloc/login_cubit.dart
index 44ccd8e2..a651938d 100644
--- a/lib/features/auth/presentation/bloc/login_cubit.dart
+++ b/lib/features/auth/presentation/bloc/login_cubit.dart
@@ -28,15 +28,16 @@ class LoginCubit extends Cubit {
// if (user != null) {
// devicesCubit.fetchAllDevices(user.username);
// }
- result.fold((failure) => emit(LoginFailure(failure.message)), (user) {
+ result.fold((failure) => emit(LoginFailure(failure.message)), (user) async {
// 先更新全局用户状态(确保首页能获取到 token)
authCubit.appCubit.setAuth(user);
- // 发出登录成功状态(触发页面跳转)
- emit(LoginSuccess(user));
- // 后台异步执行 TCP 连接等初始化操作(不阻塞登录流程)
- authCubit.loginSuccess(user).catchError((e) {
+ // 🔥 先等待 TCP 连接和认证完成,确保 AuthCubit 变为 AuthAuthenticated
+ // 再触发页面导航,避免 GoRouter 拦截踢回登录页
+ await authCubit.loginSuccess(user).catchError((e) {
print('TCP 初始化失败: $e');
});
+ // 发出登录成功状态(触发页面跳转)
+ emit(LoginSuccess(user));
});
} catch (e) {
emit(LoginFailure(e.toString()));
diff --git a/lib/features/devices/data/datasources/device_task_datasource.dart b/lib/features/devices/data/datasources/device_task_datasource.dart
index 361daac6..38c4d4c2 100644
--- a/lib/features/devices/data/datasources/device_task_datasource.dart
+++ b/lib/features/devices/data/datasources/device_task_datasource.dart
@@ -52,9 +52,11 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
}) async {
try {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/deviceTaskPool';
- final response = await dio.get(
+ _logger.logWithLevel('[getDeviceTaskPool] 请求: POST $url');
+ _logger.logWithLevel('[getDeviceTaskPool] 参数: userId=$userId, siteId=$siteId, orgId=$orgId');
+ final response = await dio.post(
url,
- queryParameters: {
+ data: {
'userId': userId,
'siteId': siteId,
'orgId': orgId,
@@ -89,30 +91,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
required int orgId,
required int siteId,
}) async {
+ final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
+ final body = {
+ 'deviceId': deviceId,
+ 'taskId': taskId,
+ 'orgId': orgId,
+ 'siteId': siteId,
+ };
+ _logger.logWithLevel('[cancelTask] 请求: POST $url');
+ _logger.logWithLevel('[cancelTask] 参数: ${jsonEncode(body)}');
try {
- final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
- final response = await dio.post(
- url,
- data: {
- 'deviceId': deviceId,
- 'taskId': taskId,
- 'orgId': orgId,
- 'siteId': siteId,
- },
- );
+ final response = await dio.post(url, data: body);
+ _logger.logWithLevel('[cancelTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
if (response.statusCode == 200) {
final data = response.data as Map;
if (data['code'] == 200) {
+ _logger.logWithLevel('[cancelTask] 结果: 成功');
return data['data'] as bool? ?? false;
} else {
+ _logger.logWithLevel('[cancelTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
throw Exception(data['msg'] ?? '取消任务失败');
}
} else {
throw Exception('HTTP ${response.statusCode}');
}
} catch (e) {
- _logger.logWithLevel('❌ 取消任务失败: $e');
+ _logger.logWithLevel('[cancelTask] 异常: $e');
rethrow;
}
}
@@ -124,30 +129,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
required int orgId,
required int siteId,
}) async {
+ final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/pauseTask';
+ final body = {
+ 'deviceId': deviceId,
+ 'taskId': taskId,
+ 'orgId': orgId,
+ 'siteId': siteId,
+ };
+ _logger.logWithLevel('[pauseTask] 请求: POST $url');
+ _logger.logWithLevel('[pauseTask] 参数: ${jsonEncode(body)}');
try {
- final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/pauseTask';
- final response = await dio.post(
- url,
- data: {
- 'deviceId': deviceId,
- 'taskId': taskId,
- 'orgId': orgId,
- 'siteId': siteId,
- },
- );
+ final response = await dio.post(url, data: body);
+ _logger.logWithLevel('[pauseTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
if (response.statusCode == 200) {
final data = response.data as Map;
if (data['code'] == 200) {
+ _logger.logWithLevel('[pauseTask] 结果: 成功');
return data['data'] as bool? ?? false;
} else {
+ _logger.logWithLevel('[pauseTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
throw Exception(data['msg'] ?? '暂停任务失败');
}
} else {
throw Exception('HTTP ${response.statusCode}');
}
} catch (e) {
- _logger.logWithLevel('❌ 暂停任务失败: $e');
+ _logger.logWithLevel('[pauseTask] 异常: $e');
rethrow;
}
}
@@ -159,30 +167,33 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
required int orgId,
required int siteId,
}) async {
+ final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/recoveryTask';
+ final body = {
+ 'deviceId': deviceId,
+ 'taskId': taskId,
+ 'orgId': orgId,
+ 'siteId': siteId,
+ };
+ _logger.logWithLevel('[recoveryTask] 请求: POST $url');
+ _logger.logWithLevel('[recoveryTask] 参数: ${jsonEncode(body)}');
try {
- final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/recoveryTask';
- final response = await dio.post(
- url,
- data: {
- 'deviceId': deviceId,
- 'taskId': taskId,
- 'orgId': orgId,
- 'siteId': siteId,
- },
- );
+ final response = await dio.post(url, data: body);
+ _logger.logWithLevel('[recoveryTask] 响应: statusCode=${response.statusCode}, body=${jsonEncode(response.data)}');
if (response.statusCode == 200) {
final data = response.data as Map;
if (data['code'] == 200) {
+ _logger.logWithLevel('[recoveryTask] 结果: 成功, data=${jsonEncode(data['data'])}');
return data['data'] as Map? ?? {};
} else {
+ _logger.logWithLevel('[recoveryTask] 业务失败: code=${data['code']}, msg=${data['msg']}');
throw Exception(data['msg'] ?? '恢复任务失败');
}
} else {
throw Exception('HTTP ${response.statusCode}');
}
} catch (e) {
- _logger.logWithLevel('❌ 恢复任务失败: $e');
+ _logger.logWithLevel('[recoveryTask] 异常: $e');
rethrow;
}
}
diff --git a/lib/features/devices/presentation/bloc/device_status_bloc.dart b/lib/features/devices/presentation/bloc/device_status_bloc.dart
index 3c60cfb3..2ca3eeb8 100644
--- a/lib/features/devices/presentation/bloc/device_status_bloc.dart
+++ b/lib/features/devices/presentation/bloc/device_status_bloc.dart
@@ -8,6 +8,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_entity.dar
import 'package:maibu_satabot_v2/core/network/protocol_decoder.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/repositories/task_message_repository.dart';
import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_arrive_entity.dart';
+import 'package:maibu_satabot_v2/core/network/mqtt/domain/entities/task_status_entity.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/network/net_message_dispatcher.dart';
@@ -27,6 +28,7 @@ class DeviceStatusBloc extends Bloc {
// 🔥 保存订阅引用,用于管理生命周期
StreamSubscription? _tcpSubscription;
StreamSubscription? _mqttArriveSubscription;
+ StreamSubscription? _mqttStatusSubscription;
// 🔥 节流相关:500ms节流控制0x02数据推送频率
Timer? _throttleTimer;
@@ -34,6 +36,9 @@ class DeviceStatusBloc extends Bloc {
RunningStatusEntity? _cachedStatus;
GPSEntity? _cachedGps;
+ // 🔥 调试计数器:跟踪0x02收包序号,排查断断续续问题
+ int _packetSeq = 0;
+
// 🔥 当前监听的设备ID
String? _currentDeviceId;
@@ -46,6 +51,9 @@ class DeviceStatusBloc extends Bloc {
// 🔥 初始化MQTT到达点监听
_initMqttArriveListener();
+ // 🔥 初始化MQTT任务状态监听(接收完成推送)
+ _initMqttStatusListener();
+
// 保留事件处理(用于手动重置等场景)
on(_handleReset);
on(_handleDeviceStatusLoaded);
@@ -89,23 +97,27 @@ class DeviceStatusBloc extends Bloc {
// 🔥 关键修复:直接监听 tcpClient.packetStream,不经过 dispatcher 的 filtered stream
// 这样即使没有其他监听者,TCP流也不会暂停
_tcpSubscription = tcpClient.packetStream
- .where((p) => p.command == 0x02)
+ .where((p) {
+ final is02 = p.command == 0x02;
+ if (is02) {
+ _packetSeq++;
+ debugPrint('📥 [0x02] #$_packetSeq 收到原始TCP包 | payload长度=${p.payload.length} | ${DateTime.now().toString().substring(11, 19)}');
+ }
+ return is02;
+ })
.map((p) {
try {
final result = utf8.decode(p.payload, allowMalformed: true);
- // debugPrint('✅ [DeviceStatusBloc] 收到0x02数据: $result');
return result;
} catch (e) {
- // debugPrint('❌ [DeviceStatusBloc] 解码失败: $e');
+ debugPrint('❌ [0x02] #$_packetSeq 解码失败: $e, payload前20字节=${p.payload.take(20).toList()}');
return '';
}
})
.listen(
(message) {
- //debugPrint('📩 [DeviceStatusBloc] 直接收到0x02数据,长度:${message.length}');
-
if (message.isEmpty) {
- //debugPrint('⚠️ [DeviceStatusBloc] 消息为空,跳过');
+ debugPrint('⚠️ [0x02] #$_packetSeq 解码后为空,跳过');
return;
}
@@ -114,7 +126,7 @@ class DeviceStatusBloc extends Bloc {
final fields = message.trim().split(',');
if (fields.length < 18) {
- //debugPrint('⚠️ [DeviceStatusBloc] 字段不足:${fields.length},期望≥18');
+ debugPrint('⚠️ [0x02] #$_packetSeq 字段不足:${fields.length},期望≥18, 原始数据前100字符=${message.substring(0, message.length > 100 ? 100 : message.length)}');
// 🔥 错误不节流,立即emit以便UI显示错误
if (!isClosed) {
emit(DeviceStatusError('字段不足,期望≥18,实际:${fields.length}'));
@@ -125,6 +137,8 @@ class DeviceStatusBloc extends Bloc {
final status = RunningStatusEntity.fromFields(fields);
final gps = GPSEntity(status.latitude, status.longitude);
+ debugPrint('✅ [0x02] #$_packetSeq 解析成功 | 字段数=${fields.length} | 电压=${status.voltage}V 电量=${status.battery}% 控制模式=${status.controlMode} | 节流等待${_throttleDuration.inMilliseconds}ms');
+
// 🔥 缓存最新数据用于节流发射
_cachedStatus = RunningStatusEntity(
voltage: status.voltage,
@@ -154,13 +168,14 @@ class DeviceStatusBloc extends Bloc {
);
_cachedGps = GPSEntity(status.latitude, status.longitude);
- // 🔥 节流:取消之前的timer,重新计时500ms
- _throttleTimer?.cancel();
- _throttleTimer = Timer(_throttleDuration, () {
- _emitCachedStatus();
- });
+ // 🔥 节流:如果定时器已在运行,只更新缓存不重置;否则启动新的500ms节流周期
+ if (_throttleTimer == null || !_throttleTimer!.isActive) {
+ _throttleTimer = Timer(_throttleDuration, () {
+ _emitCachedStatus();
+ });
+ }
} catch (e, stack) {
- //debugPrint('❌ [DeviceStatusBloc] 直接解析异常:$e\n$stack');
+ debugPrint('❌ [0x02] #$_packetSeq 解析异常:$e');
// _logger.log('❌ [DeviceStatusBloc] 直接解析异常:$e');
// 🔥 解析错误不节流,立即emit以便UI显示错误
if (!isClosed) {
@@ -225,6 +240,60 @@ class DeviceStatusBloc extends Bloc {
_logger.log('✅ [DeviceStatusBloc] MQTT到达点监听器已建立完成');
}
+ // 🔥 初始化MQTT任务状态监听(接收 task/+/status 完成推送)
+ void _initMqttStatusListener() {
+ debugPrint('🔗 [DeviceStatusBloc] 初始化MQTT任务状态监听器');
+ _logger.log('🔗 [DeviceStatusBloc] 初始化MQTT任务状态监听器');
+
+ _mqttStatusSubscription = _taskMessageRepo.taskStatusStream.listen(
+ (TaskStatusEntity status) {
+ debugPrint(
+ '📋 [DeviceStatusBloc] 收到MQTT任务状态: type=${status.type}, deviceId=${status.deviceId}, status=${status.status}',
+ );
+ _logger.log(
+ '📋 [DeviceStatusBloc] 收到MQTT任务状态: type=${status.type}, deviceId=${status.deviceId}, status=${status.status}',
+ );
+
+ // 检查设备ID是否匹配当前监听的设备
+ if (_currentDeviceId != null && status.deviceId != _currentDeviceId) {
+ debugPrint(
+ '⚠️ [DeviceStatusBloc] 任务状态设备ID不匹配,跳过 - 当前:$_currentDeviceId, 收到:${status.deviceId}',
+ );
+ return;
+ }
+
+ // 🔥 状态为 FINISH 表示任务完成
+ if (status.status == 'FINISH') {
+ debugPrint('🏁 [DeviceStatusBloc] 收到任务完成推送,触发完成流程');
+ _logger.log('🏁 [DeviceStatusBloc] 收到任务完成推送,触发完成流程');
+
+ final devicesCubit = GetIt.I();
+ devicesCubit.finishWork();
+
+ // 🔥 无论缓存是否有效,都必须 emit 触发 BlocBuilder 重建
+ if (!isClosed) {
+ emit(DeviceStatusUpdated(
+ _cachedStatus ?? RunningStatusEntity(),
+ _cachedGps ?? GPSEntity(0.0, 0.0),
+ ));
+ debugPrint('📤 [DeviceStatusBloc] 已 emit 完成信号,触发 UI 更新');
+ }
+
+ Future.delayed(Duration(seconds: 1), () {
+ devicesCubit.resetWorkStatus();
+ });
+ }
+ },
+ onError: (e) {
+ debugPrint('❌ [DeviceStatusBloc] MQTT任务状态监听错误: $e');
+ _logger.log('❌ [DeviceStatusBloc] MQTT任务状态监听错误: $e');
+ },
+ );
+
+ debugPrint('✅ [DeviceStatusBloc] MQTT任务状态监听器已建立完成');
+ _logger.log('✅ [DeviceStatusBloc] MQTT任务状态监听器已建立完成');
+ }
+
// 🔥 设置当前监听的设备ID(用于过滤MQTT消息)
void setListeningDeviceId(String deviceId) {
debugPrint('📱 [DeviceStatusBloc] 设置监听设备ID: $deviceId');
@@ -235,7 +304,7 @@ class DeviceStatusBloc extends Bloc {
// 🔥 节流发射:500ms到期后发射缓存的最新数据
void _emitCachedStatus() {
if (_cachedStatus != null && _cachedGps != null && !isClosed) {
- // debugPrint('📤 [DeviceStatusBloc] 🔥节流发射 - 电压:${_cachedStatus!.voltage}, 电量:${_cachedStatus!.battery}');
+ debugPrint('📤 [0x02] 节流发射 | 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) | ${DateTime.now().toString().substring(11, 19)}');
emit(DeviceStatusUpdated(_cachedStatus!, _cachedGps!));
}
}
@@ -327,6 +396,16 @@ class DeviceStatusBloc extends Bloc {
final devicesCubit = GetIt.I();
devicesCubit.finishWork();
+
+ // 🔥 关键:无论缓存是否有效,都必须 emit 触发 BlocBuilder 重建
+ if (!isClosed) {
+ emit(DeviceStatusUpdated(
+ _cachedStatus ?? RunningStatusEntity(),
+ _cachedGps ?? GPSEntity(0.0, 0.0),
+ ));
+ debugPrint('📤 [DeviceStatusBloc] 已 emit 停止信号,触发 UI 更新');
+ }
+
Future.delayed(Duration(seconds: 1), () {
devicesCubit.resetWorkStatus();
});
@@ -341,6 +420,8 @@ class DeviceStatusBloc extends Bloc {
// 🔥 清理MQTT订阅
_mqttArriveSubscription?.cancel();
_mqttArriveSubscription = null;
+ _mqttStatusSubscription?.cancel();
+ _mqttStatusSubscription = null;
// 🔥 清理节流timer和缓存
_throttleTimer?.cancel();
diff --git a/lib/features/devices/presentation/bloc/device_task_cubit.dart b/lib/features/devices/presentation/bloc/device_task_cubit.dart
index a15edd4e..c602cfd6 100644
--- a/lib/features/devices/presentation/bloc/device_task_cubit.dart
+++ b/lib/features/devices/presentation/bloc/device_task_cubit.dart
@@ -96,7 +96,7 @@ class DeviceTaskCubit extends Cubit