绑定设备+覆盖模式+控制显示bug+在线更新

This commit is contained in:
2026-09-01 08:59:20 +08:00
parent 0fa67a127b
commit 61bce2c1cf
49 changed files with 2424 additions and 720 deletions

View File

@@ -288,7 +288,7 @@
"location": "Location",
"refresh": "Refresh",
"select_work_mode": "Select Work Mode",
"bow_mode": "Bow Mode",
"bow_mode": "Coverage Mode",
"bow_mode_subtitle": "Standard full coverage path planning",
"custom_mode": "Custom Mode",
"custom_mode_subtitle": "Manually set work area",

View File

@@ -302,7 +302,7 @@
"location": "定位",
"refresh": "刷新",
"select_work_mode": "选择作业模式",
"bow_mode": "弓字模式",
"bow_mode": "覆盖模式",
"bow_mode_subtitle": "标准全覆盖路径规划",
"custom_mode": "自定义模式",
"custom_mode_subtitle": "手动设定作业区域",

4
dist/manifest.json vendored
View File

@@ -1,6 +1,6 @@
{
"version": "1.0.13",
"md5": "181176ea51ec26ac97f34c2a550661f0",
"version": "1.0.7",
"md5": "3e9ad72a89880b96b024eeacec9518c6",
"targetVersionCode": 1,
"abi": "arm64-v8a"
}

12
fix_json.ps1 Normal file
View File

@@ -0,0 +1,12 @@
$zhPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\zh-CN.json'
$enPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\en-US.json'
$zhContent = [System.IO.File]::ReadAllText($zhPath)
$zhContent = $zhContent.Replace('"bow_mode": "弓字模式"', '"bow_mode": "覆盖模式"')
[System.IO.File]::WriteAllText($zhPath, $zhContent)
$enContent = [System.IO.File]::ReadAllText($enPath)
$enContent = $enContent.Replace('"bow_mode": "Bow Mode"', '"bow_mode": "Coverage Mode"')
[System.IO.File]::WriteAllText($enPath, $enContent)
Write-Host "Done!"

106
flutter_01.log Normal file
View File

@@ -0,0 +1,106 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.48:37689 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [1,623ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [9.3s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [14.1s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [20ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [17ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [8.2s]
• PKB110 (mobile) • 192.168.2.48:37689 • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
[✓] Network resources [1,268ms]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

109
flutter_02.log Normal file
View File

@@ -0,0 +1,109 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.39:42831 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [841ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [4.4s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [9.0s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [11ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [8ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (7 available) [11.6s]
• 21091116AC (mobile) • 192.168.2.16:35367 • android-arm64 • Android 13 (API 33)
• PKB110 (mobile) • 192.168.2.39:42831 • android-arm64 • Android 16 (API 36)
• PKB110 (wireless) (mobile) • adb-4TRKWOEE4HR8AMEA-ti69Ed._adb-tls-connect._tcp • android-arm64 • Android 16 (API 36)
• 21091116AC (wireless) (mobile) • adb-QK4LCEO78TWOVKOB-v9ALzk._adb-tls-connect._tcp • android-arm64 • Android 13 (API 33)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
[✓] Network resources [939ms]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

View File

@@ -14,6 +14,7 @@ import '../features/devices/presentation/bloc/device_status_event.dart';
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';
import '../core/logging/log_time.dart';
const int DATA_TIMEOUT_SECONDS = 15;
@@ -49,6 +50,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
@override
void initState() {
super.initState();
debugPrint('${LogTime.now()} 🖥️ [设备数据监控] 页面打开,开始展示当前订阅设备的数据');
_startDataTimeoutTimer();
// 🔥 页面初始化时自动调用刷新
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -64,6 +66,7 @@ class _DeviceStatusModalState extends State<DeviceStatusModal> {
@override
void dispose() {
debugPrint('${LogTime.now()} 🚪 [设备数据监控] 页面关闭(MQTT订阅保持,后台继续收数据)');
_dataTimeoutTimer?.cancel();
super.dispose();
}

View File

@@ -1,123 +1,125 @@
import '../env/env_config.dart';
class HttpApiConsts {
// static const String baseUrl = "http://8.159.134.0:8012"; // 旧地址
// static const String baseUrl = "http://1.95.137.212:59015"; // 测试地址
// static const String baseUrl = "http://1.95.137.212:8081"; // 生产地址(暂时切到测试服 59003,回退时恢复此行)
static const String baseUrl = "http://1.95.137.212:59003"; // 测试服地址
static String get baseUrl => EnvConfig.baseUrl;
/// 账号相关
// 登录
static const String loginUrl = "$baseUrl/login";
static String get loginUrl => "$baseUrl/login";
///设备相关
// 获取设备列表
static const String getUserDevicesList = "$baseUrl/iot/device/list";
static String get getUserDevicesList => "$baseUrl/iot/device/list";
// 绑定设备
static const String bindDevice = "$baseUrl/iot/device/bind";
static String get bindDevice => "$baseUrl/iot/device/bind";
// 解绑设备
static const String unbindDevice = "$baseUrl/forward/device/unbind";
static String get unbindDevice => "$baseUrl/forward/device/unbind";
// 切换设备
static const String switchDevice = "$baseUrl/forward/device/switchDevice";
static String get switchDevice => "$baseUrl/forward/device/switchDevice";
// 获取光伏电站列表
static const String getSiteList = "$baseUrl/system/site/selectByUserId";
static String get getSiteList => "$baseUrl/system/site/selectByUserId";
// 获取场站下的设备列表
static const String getSiteDeviceList = "$baseUrl/iot/device/getSiteList";
static String get getSiteDeviceList => "$baseUrl/iot/device/getSiteList";
// 获取场站下的无人机机场列表
static const String getSiteUAVList = "$baseUrl/iot/UAV/getSiteUAVList";
static String get getSiteUAVList => "$baseUrl/iot/UAV/getSiteUAVList";
// 获取UAV状态详情
static const String getUAVState = "$baseUrl/iot/UAV/getUAVState";
static String get getUAVState => "$baseUrl/iot/UAV/getUAVState";
// 切换摄像头获取视频流
static const String changeCamera = "$baseUrl/iot/UAV/changeCamera";
static String get changeCamera => "$baseUrl/iot/UAV/changeCamera";
// 获取设备位置
static const String getDeviceLocation = "$baseUrl/iot/device/userDevice";
static String get getDeviceLocation => "$baseUrl/iot/device/userDevice";
// 获取全部设备列表(用于蓝牙绑定匹配)
static String get getDeviceList => "$baseUrl/iot/device/getDeviceList";
// 获取机器人列表
static const String getRobotList = "$baseUrl/iot/device/getSiteList";
static String get getRobotList => "$baseUrl/iot/device/getSiteList";
// 获取飞行任务列表
static const String getFlightTask = "$baseUrl/iot/UAV/getFlightTask";
static String get getFlightTask => "$baseUrl/iot/UAV/getFlightTask";
// 获取飞行任务详情
static const String getFlightTaskDetail =
static String get getFlightTaskDetail =>
"$baseUrl/iot/UAV/getFlightTaskDetail";
// 获取航线列表
static const String getWayline = "$baseUrl/iot/UAV/getWayline";
static String get getWayline => "$baseUrl/iot/UAV/getWayline";
// 创建飞行任务
static const String createFlightTask = "$baseUrl/iot/UAV/createFlightTask";
static String get createFlightTask => "$baseUrl/iot/UAV/createFlightTask";
// 更新飞行任务状态
static const String updateFlightTaskStatus =
static String get updateFlightTaskStatus =>
"$baseUrl/iot/UAV/updateFlightTaskStatus";
// 切换无人机镜头获取视频流
static const String changeUAVLens = "$baseUrl/iot/UAV/changeLens";
static String get changeUAVLens => "$baseUrl/iot/UAV/changeLens";
// 获取无人机详情
static const String getUAVDetail = "$baseUrl/iot/UAV/getUAVDetail";
static String get getUAVDetail => "$baseUrl/iot/UAV/getUAVDetail";
// 飞行任务命令控制(暂停、返航等)
static const String flightTaskCommand = "$baseUrl/iot/UAV/flightTaskCommand";
static String get flightTaskCommand => "$baseUrl/iot/UAV/flightTaskCommand";
/// 告警相关
static const String alarmBaseUrl = baseUrl;
static String get alarmBaseUrl => baseUrl;
// 获取告警工单配置列表
static const String alarmOrderConfigList =
static String get alarmOrderConfigList =>
"$alarmBaseUrl/iot/alarmOrderConfig/list";
// 获取告警列表
static const String alarmList = "$baseUrl/iot/alarm/list";
static String get alarmList => "$baseUrl/iot/alarm/list";
// 获取告警详情
static const String alarmDetail = "$baseUrl/iot/alarm";
static String get alarmDetail => "$baseUrl/iot/alarm";
// 处理告警(确认/关闭等)
static const String alarmHandle = "$baseUrl/iot/alarm/handle";
static String get alarmHandle => "$baseUrl/iot/alarm/handle";
/// 工单相关
// 获取工单模型配置列表
static const String orderModelList = "$baseUrl/iot/orderModel/list";
static String get orderModelList => "$baseUrl/iot/orderModel/list";
// 添加工单(上报)
static const String workOrderAdd = "$baseUrl/iot/ioTworkOrder/add";
static String get workOrderAdd => "$baseUrl/iot/ioTworkOrder/add";
// 获取工单列表
static const String workOrderList = "$baseUrl/iot/ioTworkOrder/list";
static String get workOrderList => "$baseUrl/iot/ioTworkOrder/list";
// 获取工单详情
static const String workOrderDetail = "$baseUrl/iot/ioTworkOrder";
static String get workOrderDetail => "$baseUrl/iot/ioTworkOrder";
// 派发工单
static const String workOrderDispat = "$baseUrl/iot/ioTworkOrder/dispatch";
static String get workOrderDispat => "$baseUrl/iot/ioTworkOrder/dispatch";
// 挂起工单
static const String workOrderSuspend = "$baseUrl/iot/ioTworkOrder/suspend";
static String get workOrderSuspend => "$baseUrl/iot/ioTworkOrder/suspend";
// 完成工单
static const String workOrderComplete = "$baseUrl/iot/ioTworkOrder/complete";
static String get workOrderComplete => "$baseUrl/iot/ioTworkOrder/complete";
// 开始执行工单
static const String workOrderStart = "$baseUrl/iot/ioTworkOrder/start";
static String get workOrderStart => "$baseUrl/iot/ioTworkOrder/start";
// 获取工单统计
static const String workOrderCount = "$baseUrl/iot/ioTworkOrder/count";
static String get workOrderCount => "$baseUrl/iot/ioTworkOrder/count";
// 设备运行参数查询
static const String deviceRunParamSelect =
static String get deviceRunParamSelect =>
"$baseUrl/iot/deviceRunParam/selectByDeviceId";
// 设备运行参数保存
static const String deviceRunParamSave = "$baseUrl/iot/deviceRunParam/save";
static String get deviceRunParamSave => "$baseUrl/iot/deviceRunParam/save";
// 设备操作权限校验
static const String hasPermission = "$baseUrl/iot/device/hasPermission";
static String get hasPermission => "$baseUrl/iot/device/hasPermission";
// 释放远程控制权限(请求体:platform/type/deviceId/token)
static const String releaseControlUrl =
static String get releaseControlUrl =>
"$baseUrl/forward/device/releaseControl";
/// 用户相关
// 获取用户列表
static const String systemUserList = "$baseUrl/system/user/list";
static String get systemUserList => "$baseUrl/system/user/list";
/// 组织与场站相关
// 获取组织列表
static const String orgList = "$baseUrl/system/org/list";
static String get orgList => "$baseUrl/system/org/list";
// 根据组织ID获取场站列表
static const String siteListByOrgId = "$baseUrl/system/site/selectByOrgId";
static String get siteListByOrgId => "$baseUrl/system/site/selectByOrgId";
// 根据场站ID获取用户列表
static const String userListBySiteId = "$baseUrl/system/user/list";
static String get userListBySiteId => "$baseUrl/system/user/list";
}

View File

@@ -78,6 +78,7 @@ import '../../features/v2/device_list/domain/repositories/device_repository.dart
as device_v2_domain;
import '../../features/v2/device_list/domain/usecases/get_device_status_data_usecase.dart'
as device_v2_usecase;
import '../../features/v2/device_list/domain/usecases/get_all_devices_usecase.dart';
import '../../features/v2/device_list/presentation/bloc/device_status_bloc.dart'
as device_v2_bloc;
import '../../features/v2/device_list/data/datasources/drone_station_datasource.dart';
@@ -151,10 +152,13 @@ import '../network/mqtt/domain/interfaces/mqtt_client.dart';
import '../network/mqtt/data/infrastructure/mqtt_client_impl.dart';
import '../network/mqtt/data/datasources/drone_osd_datasource.dart';
import '../network/mqtt/data/datasources/task_message_datasource.dart';
import '../network/mqtt/data/datasources/mower_realtime_datasource.dart';
import '../network/mqtt/domain/repositories/drone_osd_repository.dart';
import '../network/mqtt/domain/repositories/task_message_repository.dart';
import '../network/mqtt/domain/repositories/mower_realtime_repository.dart';
import '../network/mqtt/data/repositories/drone_osd_repository_impl.dart';
import '../network/mqtt/data/repositories/task_message_repository_impl.dart';
import '../network/mqtt/data/repositories/mower_realtime_repository_impl.dart';
import '../router/app_router.dart';
import '../storage/impl/user_storage_impl.dart';
import '../storage/user_storage.dart';
@@ -177,15 +181,20 @@ Future<void> init() async {
);
sl.registerLazySingleton(() => PathPlanningService());
/// 1.1.3 MQTT Clients - 两个独立的 MQTT 客户端实例
/// 1.1.3 MQTT Clients - 三个独立的 MQTT 客户端实例
sl.registerLazySingleton<MqttClient>(
() => MqttClientImpl(),
() => MqttClientImpl(clientName: 'droneOsdClient'),
instanceName: 'droneOsdClient',
);
sl.registerLazySingleton<MqttClient>(
() => MqttClientImpl(),
() => MqttClientImpl(clientName: 'taskMessageClient'),
instanceName: 'taskMessageClient',
);
// 🔥 割草机机器状态专用 MQTT 客户端(1.95.137.212:59007)
sl.registerLazySingleton<MqttClient>(
() => MqttClientImpl(clientName: 'mowerRealtimeClient'),
instanceName: 'mowerRealtimeClient',
);
/// 1.1.4 NetMessageDispatcher:消息调度器,并将 TcpClient 注入给它
sl.registerLazySingleton(
@@ -228,6 +237,14 @@ Future<void> init() async {
),
);
/// 割草机机器状态(MQTT):车辆实时+定位,LazySingleton 跨页面共享同一订阅
/// 🔥 使用独立 MQTT 连接 mowerRealtimeClient(1.95.137.212:59007)
sl.registerLazySingleton<MowerRealtimeDataSource>(
() => MowerRealtimeDataSourceImpl(
sl<MqttClient>(instanceName: 'mowerRealtimeClient'),
),
);
/// 1.5 --- MQTT Repositories ---
sl.registerLazySingleton<DroneOsdRepository>(
() => DroneOsdRepositoryImpl(sl<DroneOsdDataSource>()),
@@ -235,6 +252,9 @@ Future<void> init() async {
sl.registerLazySingleton<TaskMessageRepository>(
() => TaskMessageRepositoryImpl(sl<TaskMessageDataSource>()),
);
sl.registerLazySingleton<MowerRealtimeRepository>(
() => MowerRealtimeRepositoryImpl(sl<MowerRealtimeDataSource>()),
);
/// 2. 数据源 (DataSource)
sl.registerLazySingleton<AuthHttpDataSource>(
@@ -357,6 +377,9 @@ Future<void> init() async {
sl.registerLazySingleton<device_v2_usecase.GetDeviceStatusDataUseCase>(
() => device_v2_usecase.GetDeviceStatusDataUseCase(repository: sl()),
);
sl.registerLazySingleton<GetAllDevicesUseCase>(
() => GetAllDevicesUseCase(repository: sl()),
);
sl.registerFactory<device_v2_bloc.DeviceStatusBloc>(
() => device_v2_bloc.DeviceStatusBloc(sl()),
);

View File

@@ -14,14 +14,14 @@ class EnvConfig {
static bool get isProduction => environment == 'prod';
/// TCP 服务器配置
static String get tcpIp => '1.95.137.212';
// ─── 服务器 IP(测试/生产共用同一台机器) ───
static const String serverHost = '1.95.137.212';
static int get tcpPort {
// 测试服: 59016, 生产服: 9001(目前暂时都走测试服 59004,回退时改回 9001)
if (environment == 'prod') {
return 59004;
}
return 59004; // TCP 端口
}
// ─── TCP 控制长连接 ───
static String get tcpIp => serverHost;
static int get tcpPort => isProduction ? 9001 : 59004;
// ─── HTTP 接口 ───
static int get httpPort => isProduction ? 8081 : 59003;
static String get baseUrl => 'http://$serverHost:$httpPort';
}

View File

@@ -0,0 +1,14 @@
/// 链路日志统一时间戳工具
///
/// 用于打印"什么时间做了什么"的全链路日志,格式:[HH:mm:ss.SSS]
class LogTime {
LogTime._();
static String now() {
final now = DateTime.now();
return '[${now.hour.toString().padLeft(2, '0')}:'
'${now.minute.toString().padLeft(2, '0')}:'
'${now.second.toString().padLeft(2, '0')}.'
'${now.millisecond.toString().padLeft(3, '0')}]';
}
}

View File

@@ -0,0 +1,178 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../../../logging/log_time.dart';
import '../../domain/interfaces/mqtt_client.dart';
import '../../domain/models/mqtt_message.dart';
import '../../domain/entities/mower_location_entity.dart';
import '../../domain/entities/real_time_message_entity.dart';
import '../../domain/entities/route_status_entity.dart';
/// 割草机实时状态 MQTT 数据源(替代原 TCP 0x02 机器状态推送)
///
/// 订阅主题:
/// - 车辆实时状态 mower/{sn}/property/realtime/post(1Hz,不含定位)
/// - 高频定位 mower/{sn}/property/location/post(5-10Hz)
/// - 路径规划 device/{deviceId}/route/post(任务执行后推送覆盖清扫路线)
abstract class MowerRealtimeDataSource {
Stream<RealTimeMessageEntity> get vehicleStream;
Stream<MowerLocationEntity> get locationStream;
Stream<RouteStatusEntity> get routeStatusStream;
/// 按设备 SN 启动订阅:自动退订旧设备主题、同 SN 去重
Future<void> startListening({required String deviceSn});
Future<void> stopListening();
}
class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
final MqttClient mqttClient;
static const String _realtimeSuffix = 'property/realtime/post';
static const String _locationSuffix = 'property/location/post';
static const String _routeSuffix = 'route/post';
final _vehicleController =
StreamController<RealTimeMessageEntity>.broadcast();
final _locationController = StreamController<MowerLocationEntity>.broadcast();
final _routeController = StreamController<RouteStatusEntity>.broadcast();
StreamSubscription<MqttMessage>? _subscription;
/// 当前监听的设备 SN
String? _deviceSn;
/// 收帧计数(用于标记首帧)
int _vehicleMsgCount = 0;
int _locationMsgCount = 0;
int _routeMsgCount = 0;
MowerRealtimeDataSourceImpl(this.mqttClient);
@override
Stream<RealTimeMessageEntity> get vehicleStream => _vehicleController.stream;
@override
Stream<MowerLocationEntity> get locationStream => _locationController.stream;
@override
Stream<RouteStatusEntity> get routeStatusStream => _routeController.stream;
String _realtimeTopic(String sn) => 'mower/$sn/$_realtimeSuffix';
String _locationTopic(String sn) => 'mower/$sn/$_locationSuffix';
String _routeTopic(String sn) => 'device/$sn/$_routeSuffix';
@override
Future<void> startListening({required String deviceSn}) async {
if (deviceSn.isEmpty) {
debugPrint('${LogTime.now()} ⚠️ [MowerRealtimeDataSource] 订阅跳过:设备SN为空');
return;
}
// 🔥 同一设备重复调用:去重,不重复订阅(参照 DroneOsdDataSource 已验证的模式)
if (_deviceSn == deviceSn && _subscription != null) {
debugPrint('${LogTime.now()} [MowerRealtimeDataSource] 已在监听该设备,跳过: $deviceSn');
return;
}
// 🔥 切换设备:先退订旧设备的主题,防止数据串台
if (_deviceSn != null && _deviceSn!.isNotEmpty) {
await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!));
await mqttClient.unsubscribe(_locationTopic(_deviceSn!));
await mqttClient.unsubscribe(_routeTopic(_deviceSn!));
debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 已退订旧设备: $_deviceSn');
}
_deviceSn = deviceSn;
_vehicleMsgCount = 0;
_locationMsgCount = 0;
_routeMsgCount = 0;
await mqttClient.subscribe(_realtimeTopic(deviceSn));
await mqttClient.subscribe(_locationTopic(deviceSn));
await mqttClient.subscribe(_routeTopic(deviceSn));
// 消息流监听只建立一次,按 _deviceSn 过滤
_subscription ??= mqttClient.messageStream?.listen(_handleMessage);
debugPrint('${LogTime.now()} ✅ [MowerRealtimeDataSource] 开始监听设备: $deviceSn');
debugPrint(' 车辆实时: ${_realtimeTopic(deviceSn)}');
debugPrint(' 定位: ${_locationTopic(deviceSn)}');
debugPrint(' 路径规划: ${_routeTopic(deviceSn)}');
}
@override
Future<void> stopListening() async {
if (_deviceSn == null || _deviceSn!.isEmpty) return;
await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!));
await mqttClient.unsubscribe(_locationTopic(_deviceSn!));
await mqttClient.unsubscribe(_routeTopic(_deviceSn!));
debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 停止监听设备: $_deviceSn');
_deviceSn = null;
}
void _handleMessage(MqttMessage message) {
final sn = _deviceSn;
if (sn == null || sn.isEmpty) return;
// 🔥 防串台:只处理当前监听设备的主题(mower/ 和 device/ 前缀都要匹配)
if (!message.topic.contains('mower/$sn/') &&
!message.topic.contains('device/$sn/')) return;
// 🔥 第一阶段:打印原始报文,确认格式后再完善解析
debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] ──────── 收到消息 ────────');
debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] topic: ${message.topic}');
debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] payload: ${message.payload}');
try {
final jsonData = jsonDecode(message.payload) as Map<String, dynamic>;
if (message.topic.endsWith(_realtimeSuffix)) {
// {name, value, unit} 数据点格式与 RealTimeMessageEntity 吻合,防御性解析
final vehicle = RealTimeMessageEntity.fromJson(jsonData);
if (vehicle.data.isNotEmpty) {
_vehicleMsgCount++;
if (_vehicleMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 车辆实时首帧到达: $sn');
}
debugPrint('${LogTime.now()} 📊 [MQTT-机器状态] 车辆实时第$_vehicleMsgCount帧 解析后: type=${vehicle.type}, 数据点=${vehicle.data.length}个');
for (final dp in vehicle.data) {
debugPrint('${LogTime.now()} ├ ${dp.name} = ${dp.value}${dp.unit}');
}
_vehicleController.add(vehicle);
}
} else if (message.topic.endsWith(_locationSuffix)) {
_locationMsgCount++;
if (_locationMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 定位首帧到达: $sn');
}
// 🔥 关键修复:坐标在 data 子对象中,不是顶层
final dataObj = jsonData['data'] as Map<String, dynamic>? ?? jsonData;
final location = MowerLocationEntity.fromJson(dataObj);
debugPrint('${LogTime.now()} 📍 [MQTT-机器状态] 定位第$_locationMsgCount帧 解析后: lat=${location.latitude}, lng=${location.longitude}, alt=${location.altitude}, heading=${location.heading}, speed=${location.speed}');
_locationController.add(location);
} else if (message.topic.endsWith(_routeSuffix)) {
_routeMsgCount++;
if (_routeMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 路径规划首帧到达: $sn');
}
final routeMsg = RouteStatusEntity.fromJson(jsonData);
debugPrint('${LogTime.now()} 🛤️ [MQTT-机器状态] 路径规划第$_routeMsgCount帧 - 设备: ${routeMsg.deviceId}, 任务: ${routeMsg.taskId}, 点数: ${routeMsg.data.length}');
_routeController.add(routeMsg);
}
} catch (e) {
debugPrint('${LogTime.now()} ❌ [MowerRealtimeDataSource] 解析消息失败: $e');
}
}
void dispose() {
stopListening();
_subscription?.cancel();
_subscription = null;
_vehicleController.close();
_locationController.close();
_routeController.close();
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:mqtt_client/mqtt_client.dart' as mqtt;
import 'package:mqtt_client/mqtt_server_client.dart' as mqtt_server;
import '../../../../logging/log_time.dart';
import '../../domain/interfaces/mqtt_client.dart';
import '../../domain/models/mqtt_config.dart';
import '../../domain/models/mqtt_message.dart';
@@ -13,6 +14,9 @@ class MqttClientImpl implements MqttClient {
final _messageController = StreamController<MqttMessage>.broadcast();
final Map<String, int> _subscriptionRefs = <String, int>{};
/// 客户端名称,用于日志区分(如 droneOsdClient / taskMessageClient / mowerRealtimeClient)
final String clientName;
MqttConfig? _currentConfig;
StreamSubscription? _updatesSubscription;
Timer? _reconnectTimer;
@@ -20,6 +24,8 @@ class MqttClientImpl implements MqttClient {
bool _isConnecting = false;
bool _manualDisconnect = false;
MqttClientImpl({this.clientName = 'MqttClient'});
@override
Stream<MqttMessage>? get messageStream => _messageController.stream;
@@ -50,13 +56,13 @@ class MqttClientImpl implements MqttClient {
_client = _createClient(config);
_configureClient(_client!, config);
debugPrint('[MqttClient] connecting to ${config.connectionAddress}');
debugPrint('${LogTime.now()} 🔌 [$clientName] 正在连接 ${config.connectionAddress}:${config.port} (clientId=${config.clientId})');
await _client!.connect(config.username, config.password);
if (_client!.connectionStatus?.state ==
mqtt.MqttConnectionState.connected) {
_isConnected = true;
debugPrint('[MqttClient] connected');
debugPrint('${LogTime.now()} ✅ [$clientName] 连接成功 ${config.connectionAddress}:${config.port}');
_listenToMessages();
_resubscribeAll();
} else {
@@ -65,7 +71,7 @@ class MqttClientImpl implements MqttClient {
);
}
} catch (e) {
debugPrint('[MqttClient] connect error: $e');
debugPrint('${LogTime.now()} ❌ [$clientName] 连接失败: $e');
_isConnected = false;
_isConnecting = false;
_scheduleReconnect();
@@ -153,7 +159,7 @@ class MqttClientImpl implements MqttClient {
return;
}
debugPrint('[MqttClient] subscribe: $topic');
debugPrint('${LogTime.now()} 📮 [$clientName] 发起订阅: $topic');
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
}
@@ -173,7 +179,7 @@ class MqttClientImpl implements MqttClient {
return;
}
debugPrint('[MqttClient] unsubscribe: $topic');
debugPrint('${LogTime.now()} 🔕 [$clientName] 退订: $topic');
_client!.unsubscribe(topic);
}
@@ -198,7 +204,7 @@ class MqttClientImpl implements MqttClient {
(msg.payload as mqtt.MqttPublishMessage).payload.message,
);
debugPrint('[MqttClient] received [$topic]: $payload');
debugPrint('${LogTime.now()} 📥 [$clientName] 收到消息 [$topic]: $payload');
_messageController.add(MqttMessage(topic: topic, payload: payload));
}
},
@@ -215,7 +221,7 @@ class MqttClientImpl implements MqttClient {
if (_subscriptionRefs.isEmpty || _client == null) return;
for (final topic in _subscriptionRefs.keys) {
debugPrint('[MqttClient] resubscribe: $topic');
debugPrint('${LogTime.now()} 📮 [$clientName] 重连后重新订阅: $topic');
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
}
}
@@ -226,7 +232,7 @@ class MqttClientImpl implements MqttClient {
}
void _onDisconnected() {
debugPrint('[MqttClient] disconnected callback');
debugPrint('${LogTime.now()} ⚠️ [$clientName] 连接断开');
_isConnected = false;
if (!_manualDisconnect) {
_scheduleReconnect();
@@ -234,7 +240,7 @@ class MqttClientImpl implements MqttClient {
}
void _onSubscribed(String topic) {
debugPrint('[MqttClient] subscribed: $topic');
debugPrint('${LogTime.now()} ✅ [$clientName] 订阅确认成功: $topic');
}
void _scheduleReconnect() {
@@ -246,12 +252,12 @@ class MqttClientImpl implements MqttClient {
if (_reconnectTimer?.isActive ?? false) return;
final delay = Duration(milliseconds: config.reconnectDelayMs);
debugPrint('[MqttClient] reconnect scheduled in ${delay.inMilliseconds}ms');
debugPrint('${LogTime.now()} ⏳ [$clientName] ${delay.inMilliseconds}ms 后尝试重连');
_reconnectTimer = Timer(delay, () async {
if (_manualDisconnect || _isConnected || _isConnecting) return;
try {
debugPrint('[MqttClient] reconnecting...');
debugPrint('${LogTime.now()} 🔄 [$clientName] 开始重连...');
await connect(config);
} catch (e) {
debugPrint('[MqttClient] reconnect failed: $e');

View File

@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../datasources/mower_realtime_datasource.dart';
import '../../domain/repositories/mower_realtime_repository.dart';
import '../../domain/entities/mower_location_entity.dart';
import '../../domain/entities/real_time_message_entity.dart';
import '../../domain/entities/route_status_entity.dart';
class MowerRealtimeRepositoryImpl implements MowerRealtimeRepository {
final MowerRealtimeDataSource dataSource;
MowerRealtimeRepositoryImpl(this.dataSource);
@override
Stream<RealTimeMessageEntity> get vehicleStream => dataSource.vehicleStream;
@override
Stream<MowerLocationEntity> get locationStream => dataSource.locationStream;
@override
Stream<RouteStatusEntity> get routeStatusStream =>
dataSource.routeStatusStream;
@override
Future<Either<Failure, void>> startListening({
required String deviceSn,
}) async {
try {
await dataSource.startListening(deviceSn: deviceSn);
return right(null);
} catch (e) {
return left(Failure(e.toString()));
}
}
@override
Future<void> stopListening() async {
await dataSource.stopListening();
}
}

View File

@@ -0,0 +1,54 @@
import 'package:equatable/equatable.dart';
/// 割草机定位消息实体(MQTT 主题 mower/{sn}/property/location/post,5-10Hz)
///
/// 报文格式尚未最终确认(第一阶段仅打印),fromLen 对常见字段名做宽容解析,
/// 确认后如与实际不符在此调整即可,不影响上层。
class MowerLocationEntity extends Equatable {
final double? latitude;
final double? longitude;
final double? altitude;
/// 航向角
final double? heading;
/// 速度
final double? speed;
final String? timestamp;
/// 原始报文,便于解析格式确认后补充字段
final Map<String, dynamic> rawData;
const MowerLocationEntity({
this.latitude,
this.longitude,
this.altitude,
this.heading,
this.speed,
this.timestamp,
this.rawData = const {},
});
bool get isValid => latitude != null && longitude != null;
factory MowerLocationEntity.fromJson(Map<String, dynamic> json) {
return MowerLocationEntity(
latitude: _toDouble(json['latitude'] ?? json['lat']),
longitude: _toDouble(json['longitude'] ?? json['lng'] ?? json['lon']),
altitude: _toDouble(json['altitude'] ?? json['height']),
heading: _toDouble(json['heading'] ?? json['yaw']),
speed: _toDouble(json['speed']),
timestamp: json['timestamp']?.toString(),
rawData: json,
);
}
static double? _toDouble(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
return double.tryParse(value.toString());
}
@override
List<Object?> get props => [latitude, longitude, altitude, heading, speed, timestamp];
}

View File

@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
/// MQTT 路径规划消息实体
/// 对应后端 Java DTO: WebRouteStatusMessageDTO
/// Topic: device/{deviceId}/route/post
class RouteStatusEntity extends Equatable {
final List<LatAndLngEntity> data;
final String deviceId;
final int taskId;
final String type;
const RouteStatusEntity({
required this.data,
required this.deviceId,
required this.taskId,
required this.type,
});
factory RouteStatusEntity.fromJson(Map<String, dynamic> json) {
final dataList = json['data'] as List<dynamic>? ?? [];
return RouteStatusEntity(
data: dataList.map((item) => LatAndLngEntity.fromJson(item)).toList(),
deviceId: json['deviceId'] as String? ?? '',
taskId: (json['taskId'] as num?)?.toInt() ?? 0,
type: json['type'] as String? ?? '',
);
}
@override
List<Object?> get props => [data, deviceId, taskId, type];
}
/// 经纬度坐标实体
/// 对应后端 Java DTO: LatAndLngEntity
class LatAndLngEntity extends Equatable {
final double lat;
final double lng;
const LatAndLngEntity({required this.lat, required this.lng});
factory LatAndLngEntity.fromJson(Map<String, dynamic> json) {
return LatAndLngEntity(
lat: (json['lat'] as num?)?.toDouble() ?? 0.0,
lng: (json['lng'] as num?)?.toDouble() ?? 0.0,
);
}
@override
List<Object?> get props => [lat, lng];
}

View File

@@ -81,6 +81,21 @@ class MqttConfig extends Equatable {
);
}
/// 🔥 割草机机器状态(TCP MQTT)——独立连接,地址 1.95.137.212:59007
/// 仅用于 mower/{sn}/property/realtime/post 和 mower/{sn}/property/location/post
factory MqttConfig.mowerRealtime() {
return const MqttConfig(
host: '1.95.137.212',
port: 59007,
username: 'maibu',
password: 'jsmbzn520',
protocol: MqttProtocol.tcp,
clientId: 'mower_realtime_client',
cleanSession: true,
reconnectDelayMs: 3000,
);
}
MqttConfig copyWith({
String? host,
int? port,

View File

@@ -0,0 +1,19 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../entities/mower_location_entity.dart';
import '../entities/real_time_message_entity.dart';
import '../entities/route_status_entity.dart';
abstract class MowerRealtimeRepository {
Stream<RealTimeMessageEntity> get vehicleStream;
Stream<MowerLocationEntity> get locationStream;
Stream<RouteStatusEntity> get routeStatusStream;
Future<Either<Failure, void>> startListening({
required String deviceSn,
});
Future<void> stopListening();
}

View File

@@ -24,10 +24,12 @@ class MqttManager {
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
final mowerRealtimeClient = sl<MqttClient>(instanceName: 'mowerRealtimeClient');
// 独立连接两个 MQTT 客户端,互不影响
// 独立连接三个 MQTT 客户端,互不影响
await _connectClient(droneOsdClient, MqttConfig.droneOsd(), 'droneOsdClient');
await _connectClient(taskMessageClient, MqttConfig.taskMessage(), 'taskMessageClient');
await _connectClient(mowerRealtimeClient, MqttConfig.mowerRealtime(), 'mowerRealtimeClient');
_isInitialized = true;
debugPrint('✅ [MqttManager] MQTT 初始化完成');
@@ -53,9 +55,11 @@ class MqttManager {
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
final mowerRealtimeClient = sl<MqttClient>(instanceName: 'mowerRealtimeClient');
await droneOsdClient.disconnect();
await taskMessageClient.disconnect();
await mowerRealtimeClient.disconnect();
_isInitialized = false;
debugPrint('✅ [MqttManager] 所有 MQTT 连接已断开');

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_patcher/flutter_patcher.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
@@ -56,6 +57,7 @@ class VersionCheckService {
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
validateStatus: (status) => true,
));
static const String apiUrl = 'http://8.159.134.0:8012/api/update/check';
@@ -67,29 +69,26 @@ class VersionCheckService {
required int currentVersionCode,
}) async {
try {
_logger.i('🔍 检查版本更新');
_logger.i('📱 APK 自带版本: $currentVersion ($currentVersionCode)');
debugPrint('');
debugPrint('========================================');
debugPrint(' UPDATE CHECK START');
debugPrint('========================================');
debugPrint('[1] APK version: $currentVersion ($currentVersionCode)');
// 从文件读取已应用的补丁版本
final patchInfo = await _loadPatchVersionInfo();
final appliedPatchVersion = patchInfo['version'] as String?;
final appliedPatchVersionCode = patchInfo['versionCode'] as int?;
_logger.i('📂 读取到的补丁版本: $appliedPatchVersion ($appliedPatchVersionCode)');
debugPrint('[2] Patch file version: $appliedPatchVersion ($appliedPatchVersionCode)');
// 如果打过补丁,使用补丁的版本;否则使用 APK 自带版本
final requestVersion = appliedPatchVersion ?? currentVersion;
final requestVersionCode = appliedPatchVersionCode ?? currentVersionCode;
// 始终用 APK 基础版本请求服务端,让服务端返回最新版本
// 客户端自己判断本地是否已经是最新
final requestVersion = currentVersion;
final requestVersionCode = currentVersionCode;
_logger.i('🚀 请求后端版本: $requestVersion ($requestVersionCode)');
// 🔥 打印完整的请求参数
_logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
_logger.i('📤 App 发送的请求参数:');
_logger.i(' URL: $apiUrl');
_logger.i(' version: $requestVersion');
_logger.i(' versionCode: $requestVersionCode');
_logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
debugPrint('[3] Request to server: version=$requestVersion, versionCode=$requestVersionCode');
debugPrint('[4] URL: $apiUrl?version=$requestVersion&versionCode=$requestVersionCode');
final response = await _dio.get(
apiUrl,
@@ -99,47 +98,67 @@ class VersionCheckService {
},
);
// 🔥 打印完整的后端返回数据
_logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
_logger.i('📥 后端返回的完整数据:');
_logger.i(' statusCode: ${response.statusCode}');
_logger.i(' 原始响应: ${response.data}');
_logger.i('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
debugPrint('[5] HTTP status: ${response.statusCode}');
debugPrint('[6] Response body: ${response.data}');
if (response.statusCode == 200) {
final respCode = response.data['code'];
final data = response.data['data'];
final message = response.data['message'] ?? '';
debugPrint('[7] respCode=$respCode, data=$data, message=$message');
if (respCode != 200 || data == null) {
debugPrint('[RESULT] No update - server returned code=$respCode, data=${data == null ? "NULL" : "NOT_NULL"}, message=$message');
debugPrint('========================================');
debugPrint('');
return null;
}
debugPrint('[7.1] data type: ${data.runtimeType}');
debugPrint('[7.2] data keys: ${data is Map ? data.keys.toList() : "NOT A MAP"}');
debugPrint('[7.3] hasUpdate = ${data['hasUpdate']} (type: ${data['hasUpdate']?.runtimeType})');
debugPrint('[7.4] version = ${data['version']}');
debugPrint('[7.5] versionCode = ${data['versionCode']}');
debugPrint('[7.6] updateType = ${data['updateType']}');
debugPrint('[7.7] patch = ${data['patch']}');
debugPrint('[7.8] fullApk = ${data['fullApk']}');
if (data['hasUpdate'] == false) {
_logger.i('✅ 已是最新版本');
debugPrint('[RESULT] No update - hasUpdate=false');
debugPrint('========================================');
debugPrint('');
return null;
}
final versionInfo = AppVersionInfo.fromJson(data);
debugPrint('[8] Server version: ${versionInfo.version} (${versionInfo.versionCode})');
debugPrint('[9] Update type: ${versionInfo.updateType}, force: ${versionInfo.forceUpdate}');
debugPrint('[10] patchUrl: ${versionInfo.patchUrl}');
debugPrint('[11] patchMd5: ${versionInfo.patchMd5}');
debugPrint('[12] apkUrl: ${versionInfo.apkUrl}');
// 🔥 关键修复:无论更新类型是什么,只要本地已应用此版本,就跳过
// 只用版本字符串判断是否已更新:服务端 hasUpdate=true 说了算
if (appliedPatchVersion != null && appliedPatchVersion == versionInfo.version) {
_logger.i('✅ 本地已应用版本 ${versionInfo.version},跳过更新(updateType: ${versionInfo.updateType})');
debugPrint('[RESULT] No update - already at version ${versionInfo.version}');
debugPrint('========================================');
debugPrint('');
return null;
}
// 🔥 额外检查:如果本地补丁版本code >= 服务端返回的versionCode,也跳过
if (appliedPatchVersionCode != null && appliedPatchVersionCode >= versionInfo.versionCode) {
_logger.i('✅ 本地补丁版本code ($appliedPatchVersionCode) >= 服务端versionCode (${versionInfo.versionCode}),跳过更新');
return null;
}
debugPrint('[8.1] Local patch version: ${appliedPatchVersion ?? "none"}');
debugPrint('[8.2] Server version: ${versionInfo.version}');
debugPrint('[8.3] Version different: ${appliedPatchVersion != versionInfo.version}');
_logger.i('✅ 发现新版本: ${versionInfo.version}');
_logger.i('🔄 更新类型: ${versionInfo.updateType}');
_logger.i('⚠️ 强制更新: ${versionInfo.forceUpdate}');
debugPrint('[RESULT] NEW VERSION FOUND: ${versionInfo.version} (${versionInfo.versionCode})');
debugPrint('========================================');
debugPrint('');
return versionInfo;
}
} catch (e) {
debugPrint('[RESULT] ERROR: $e');
debugPrint('========================================');
debugPrint('');
return null;
} catch (e, stack) {
_logger.e('❌ 版本检查失败: $e');
_logger.e('❌ 堆栈信息: $stack');
throw Exception('网络请求失败: $e');
}
}

View File

@@ -19,6 +19,7 @@ abstract class DeviceTaskDatasource {
required int taskId,
required int orgId,
required int siteId,
bool forceCancel = true,
});
Future<bool> pauseTask({
@@ -90,6 +91,7 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
required int taskId,
required int orgId,
required int siteId,
bool forceCancel = true,
}) async {
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
final body = {
@@ -97,6 +99,7 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
'taskId': taskId,
'orgId': orgId,
'siteId': siteId,
'forceCancel': forceCancel,
};
_logger.logWithLevel('[cancelTask] 请求: POST $url');
_logger.logWithLevel('[cancelTask] 参数: ${jsonEncode(body)}');

View File

@@ -34,7 +34,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
logger.logWithLevel(
'用户点击bindDevice方法API 请求开始',
level: 'INFO',
data: {'url': 'http://1.95.137.212:59003/iot/device/bindDevice'},
data: {'url': '${HttpApiConsts.baseUrl}/iot/device/bindDevice'},
);
var response = await dio.post(
HttpApiConsts.bindDevice,
@@ -44,7 +44,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
'请求详细参数',
level: 'DEBUG',
data: {
'url': 'http://1.95.137.212:59003/iot/device/bindDevice',
'url': '${HttpApiConsts.baseUrl}/iot/device/bindDevice',
'deviceId': deviceId,
'deviceAlias': deviceAlias,
},
@@ -122,7 +122,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
logger.logWithLevel(
'用户点击switchDevice方法API 请求开始',
level: 'INFO',
data: {'url': 'http://1.95.137.212:59003/iot/device/switchDevice'},
data: {'url': '${HttpApiConsts.baseUrl}/iot/device/switchDevice'},
);
var response = await dio.post(
HttpApiConsts.switchDevice,
@@ -138,7 +138,7 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
'请求详细参数',
level: 'DEBUG',
data: {
'url': 'http://1.95.137.212:59003/iot/device/switchDevice',
'url': '${HttpApiConsts.baseUrl}/iot/device/switchDevice',
'platform': platform,
'deviceId': deviceId,
},
@@ -219,12 +219,12 @@ class DeviceHttpDatasourceImpl implements DeviceHttpDatasource {
'请求详细参数',
level: 'DEBUG',
data: {
'url': 'http://1.95.137.212:59003/iot/device/userDevice',
'url': '${HttpApiConsts.baseUrl}/iot/device/userDevice',
'tenantName': deviceame,
},
);
final response = await dio.get(
'http://1.95.137.212:59003/iot/device/userDevice',
'${HttpApiConsts.baseUrl}/iot/device/userDevice',
queryParameters: {'tenantName': deviceame},
// options: Options(
// headers: {

View File

@@ -11,6 +11,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/errors/device_failure.d
import 'package:maibu_satabot_v2/features/devices/domain/repositories/device_hostrity_work_repository.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/consts/http_api_consts.dart';
class DeviceHostrityWorkRepositoryImpl implements DeviceHostrityWorkRepositoryRepository {
final Dio client;
@@ -35,7 +36,7 @@ class DeviceHostrityWorkRepositoryImpl implements DeviceHostrityWorkRepositoryRe
_logger.logWithLevel('用户信息获取成功,Token: $token', level: 'info');
final response = await client.get(
'http://1.95.137.212:59003/iot/device/getDeviceRunStatistics?deviceId=$deviceId',
'${HttpApiConsts.baseUrl}/iot/device/getDeviceRunStatistics?deviceId=$deviceId',
options: Options(headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer $token'}),
);

View File

@@ -37,6 +37,7 @@ class DeviceTaskRepositoryImpl implements DeviceTaskRepository {
required int taskId,
required int orgId,
required int siteId,
bool forceCancel = true,
}) async {
try {
final result = await datasource.cancelTask(
@@ -44,6 +45,7 @@ class DeviceTaskRepositoryImpl implements DeviceTaskRepository {
taskId: taskId,
orgId: orgId,
siteId: siteId,
forceCancel: forceCancel,
);
return Right(result);
} catch (e) {

View File

@@ -18,6 +18,7 @@ abstract class DeviceTaskRepository {
required int taskId,
required int orgId,
required int siteId,
bool forceCancel = true,
});
/// 暂停任务

View File

@@ -15,6 +15,7 @@ class CancelTaskUseCase implements BaseUseCase<bool, CancelTaskParams> {
taskId: params.taskId,
orgId: params.orgId,
siteId: params.siteId,
forceCancel: params.forceCancel,
);
}
}
@@ -24,11 +25,13 @@ class CancelTaskParams {
final int taskId;
final int orgId;
final int siteId;
final bool forceCancel;
CancelTaskParams({
required this.deviceId,
required this.taskId,
required this.orgId,
required this.siteId,
this.forceCancel = true,
});
}

View File

@@ -7,10 +7,12 @@ import 'package:maibu_satabot_v2/features/devices/domain/entities/running_status
import 'package:maibu_satabot_v2/features/devices/domain/entities/gps_entity.dart';
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/repositories/mower_realtime_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/logging/log_time.dart';
import '../../../../core/network/net_message_dispatcher.dart';
import '../../../../core/network/tcp/tcp_client.dart';
import 'device_status_event.dart';
@@ -20,6 +22,10 @@ import 'devices_cubit.dart';
// 定义全局的TaskMessageRepository获取方式
TaskMessageRepository get _taskMessageRepo => GetIt.I<TaskMessageRepository>();
// 🔥 MQTT机器状态数据源(替代TCP 0x02)
MowerRealtimeRepository get _mowerRealtimeRepo =>
GetIt.I<MowerRealtimeRepository>();
class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
final NetMessageDispatcher _dispatcher;
final TcpClient tcpClient; // 🔥 新增:直接访问 TcpClient
@@ -30,6 +36,10 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
StreamSubscription? _mqttArriveSubscription;
StreamSubscription? _mqttStatusSubscription;
// 🔥 MQTT机器状态订阅(车辆实时+定位,替代TCP 0x02)
StreamSubscription? _mqttVehicleSubscription;
StreamSubscription? _mqttLocationSubscription;
// 🔥 节流相关:500ms节流控制0x02数据推送频率
Timer? _throttleTimer;
static const _throttleDuration = Duration(milliseconds: 500);
@@ -42,11 +52,17 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 当前监听的设备ID
String? _currentDeviceId;
// 🔥 MQTT机器状态收帧计数(用于链路日志)
int _mqttVehicleFrameCount = 0;
int _mqttLocationFrameCount = 0;
DeviceStatusBloc(this._dispatcher, {TcpClient? client})
: tcpClient = client ?? GetIt.I<TcpClient>(),
super(DeviceStatusInitial()) {
// 🔥 核心改动:直接在构造函数中建立TCP监听,类似RemoteControlCubit
_initDirectTcpListener();
// 🔥 已停用:机器状态数据源已改为MQTT(见 startMqttRealtimeListening,
// 由用户切换设备时触发)。TCP监听逻辑保留,回滚时取消下行注释即可。
// _initDirectTcpListener();
// 🔥 初始化MQTT到达点监听
_initMqttArriveListener();
@@ -191,6 +207,86 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
_logger.log('✅ [DeviceStatusBloc] 直接TCP监听器已建立完成');
}
/// 🔥 MQTT机器状态监听(替代TCP 0x02数据源)
///
/// [deviceSn] 与TCP时代 switchDevice / connectBySwitch 的设备号获取方式一致
/// (即 DeviceEntity.deviceName),由用户点击设备列表切换设备时调用。
///
/// 第一阶段:订阅并打印原始报文;确认格式后在第二阶段解析为
/// RunningStatusEntity + GPSEntity,复用现有500ms节流逻辑。
void startMqttRealtimeListening(String deviceSn) {
if (deviceSn.isEmpty) {
debugPrint('${LogTime.now()} ⚠️ [DeviceStatusBloc] MQTT订阅跳过:设备SN为空');
return;
}
debugPrint('${LogTime.now()} 🚀 [DeviceStatusBloc] 启动MQTT机器状态监听: $deviceSn');
// 按SN订阅(数据源内部自动退旧订新、同SN去重)
_mowerRealtimeRepo.startListening(deviceSn: deviceSn).then((result) {
result.fold(
(failure) => debugPrint(
'${LogTime.now()} ❌ [DeviceStatusBloc] MQTT机器状态订阅失败: ${failure.message}',
),
(_) => debugPrint(
'${LogTime.now()} ✅ [DeviceStatusBloc] MQTT机器状态订阅完成: $deviceSn',
),
);
});
// 切换设备时重置收帧计数,便于观察新设备的首帧与频率
_mqttVehicleFrameCount = 0;
_mqttLocationFrameCount = 0;
// 流监听只建立一次,第一阶段仅打印解析结果验证链路
_mqttVehicleSubscription ??= _mowerRealtimeRepo.vehicleStream.listen((
message,
) {
_mqttVehicleFrameCount++;
debugPrint(
'${LogTime.now()} 📊 [MQTT-机器状态] 车辆实时第$_mqttVehicleFrameCount帧 type=${message.type} 数据点=${message.data.length}',
);
// TODO 第二阶段:将数据点映射到 RunningStatusEntity 字段,复用500ms节流emit
});
_mqttLocationSubscription ??= _mowerRealtimeRepo.locationStream.listen((
location,
) {
_mqttLocationFrameCount++;
debugPrint(
'${LogTime.now()} 📍 [MQTT-location] 定位第$_mqttLocationFrameCount帧 lat=${location.latitude}, lng=${location.longitude}, heading=${location.heading}',
);
// 🔥 核心:用 MQTT location 数据更新缓存并节流 emit
if (location.isValid) {
// 更新 _cachedGps
_cachedGps = GPSEntity(location.latitude!, location.longitude!);
// 更新 _cachedStatus 中的航向角和坐标(复用已有缓存或新建空壳)
final base = _cachedStatus ?? RunningStatusEntity();
_cachedStatus = base.copyWith(
latitude: location.latitude!,
longitude: location.longitude!,
yaw: location.heading ?? base.yaw,
);
// 500ms节流:与 TCP 0x02 时代逻辑一致
if (_throttleTimer == null || !_throttleTimer!.isActive) {
_throttleTimer = Timer(_throttleDuration, () {
_emitCachedStatus();
});
}
} else {
debugPrint(
'${LogTime.now()} ⚠️ [MQTT-location] 坐标无效,跳过 - lat=${location.latitude}, lng=${location.longitude}',
);
}
});
debugPrint('${LogTime.now()} ✅ [DeviceStatusBloc] MQTT机器状态监听已启动: $deviceSn');
_logger.log('✅ [DeviceStatusBloc] MQTT机器状态监听已启动: $deviceSn');
}
// 🔥 初始化MQTT到达点监听
void _initMqttArriveListener() {
debugPrint('🔗 [DeviceStatusBloc] 初始化MQTT到达点监听器');
@@ -304,7 +400,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 节流发射:500ms到期后发射缓存的最新数据
void _emitCachedStatus() {
if (_cachedStatus != null && _cachedGps != null && !isClosed) {
debugPrint('📤 [0x02] 节流发射 | 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) | ${DateTime.now().toString().substring(11, 19)}');
debugPrint('📤 [节流发射] 电压=${_cachedStatus!.voltage}V 电量=${_cachedStatus!.battery}% 经纬度=(${_cachedGps!.latitude}, ${_cachedGps!.longitude}) yaw=${_cachedStatus!.yaw} | ${DateTime.now().toString().substring(11, 19)}');
emit(DeviceStatusUpdated(_cachedStatus!, _cachedGps!));
}
}
@@ -423,6 +519,12 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
_mqttStatusSubscription?.cancel();
_mqttStatusSubscription = null;
// 🔥 清理MQTT机器状态订阅
_mqttVehicleSubscription?.cancel();
_mqttVehicleSubscription = null;
_mqttLocationSubscription?.cancel();
_mqttLocationSubscription = null;
// 🔥 清理节流timer和缓存
_throttleTimer?.cancel();
_throttleTimer = null;

View File

@@ -34,21 +34,17 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
final userCubit = GetIt.I<AppUserCubit>();
final user = userCubit.state.user;
if (user == null) {
emit(state.copyWith(
isLoading: false,
errorMessage: '用户未登录',
));
emit(state.copyWith(isLoading: false, errorMessage: '用户未登录'));
return;
}
// 获取场站ID
// 获取场站信息(siteId 和 orgId 都从当前场站取)
final siteCubit = GetIt.I<SiteCubit>();
final siteId = siteCubit.state.selectedSite?.id;
final selectedSite = siteCubit.state.selectedSite;
final siteId = selectedSite?.id;
final orgId = selectedSite?.orgId ?? 0;
if (siteId == null) {
emit(state.copyWith(
isLoading: false,
errorMessage: '未选择场站',
));
emit(state.copyWith(isLoading: false, errorMessage: '未选择场站'));
return;
}
@@ -57,125 +53,153 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
GetDeviceTaskPoolParams(
userId: user.userId ?? '',
siteId: siteId,
orgId: user.orgId ?? 0,
orgId: orgId,
),
);
result.fold(
(failure) {
_logger.logWithLevel('❌ 获取任务池失败: ${failure.message}');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
),
);
},
(taskList) {
// 🔥 过滤出当前设备 + 活跃状态的任务(NEW, EXECUTING, PAUSE)
// 🔥 过滤出当前设备 + 活跃状态的任务(NEW, EXECUTING, PAUSE, CANCELING, FAILED)
final activeTasks = taskList.where((task) {
// 1. 设备号匹配
if (task.deviceId != deviceId) return false;
// 2. 状态过滤:只保留新建、执行中、暂停中的任务
// 2. 状态过滤:保留新建、执行中、暂停中、取消中、执行失败的任务
final status = task.taskStatus;
return status == 'NEW' || // 新建
status == 'EXECUTING' || // 执行中
status == 'PAUSE'; // 暂停中
status == 'PAUSE' || // 暂停中
status == 'CANCELING' || // 取消中(用户可处理)
status == 'FAILED'; // 执行失败(用户可恢复或取消)
}).toList();
_logger.logWithLevel(
'✅ 找到 ${activeTasks.length} 个活跃任务',
'✅ [fetchAndFilterTask] deviceId=$deviceId, 任务池总数=${taskList.length}, 过滤后活跃任务数=${activeTasks.length}',
);
for (final t in taskList) {
_logger.logWithLevel(
' - taskId=${t.id}, deviceId=${t.deviceId}, status=${t.taskStatus}',
);
}
// 🔥 如果有多个任务,保存所有选项供用户选择
// 如果只有1个,直接选中
final currentTask = activeTasks.isNotEmpty ? activeTasks.first : null;
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
taskPool: taskList,
currentTask: currentTask,
currentTaskId: currentTask?.id,
activeTasks: activeTasks,
));
),
);
},
);
} catch (e) {
_logger.logWithLevel('❌ 获取任务池异常: $e');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
),
);
}
}
/// 取消任务
Future<void> cancelTask(String deviceId) async {
Future<void> cancelTask(String deviceId, {bool forceCancel = true}) async {
final taskId = state.currentTaskId;
if (taskId == null) {
emit(state.copyWith(errorMessage: '无可用任务'));
return;
}
emit(state.copyWith(
emit(
state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.cancel,
));
),
);
try {
final userCubit = GetIt.I<AppUserCubit>();
final user = userCubit.state.user;
final siteCubit = GetIt.I<SiteCubit>();
final siteId = siteCubit.state.selectedSite?.id;
final selectedSite = siteCubit.state.selectedSite;
final siteId = selectedSite?.id;
final orgId = selectedSite?.orgId ?? 0;
if (user == null || siteId == null) {
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: '参数不完整',
operationType: DeviceTaskOperationType.none,
));
),
);
return;
}
final params = CancelTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
orgId: orgId,
siteId: siteId,
forceCancel: forceCancel,
);
_logger.logWithLevel('[取消任务] 请求: POST /iot/deviceTask/cancelTask');
_logger.logWithLevel('[取消任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId');
_logger.logWithLevel(
'[取消任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=$orgId, siteId=$siteId, forceCancel=$forceCancel',
);
final result = await _cancelTaskUseCase.call(params);
result.fold(
(failure) {
_logger.logWithLevel('[取消任务] 响应失败: ${failure.message}');
_logger.logWithLevel('❌ 取消任务失败: ${failure.message}');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
),
);
},
(success) {
_logger.logWithLevel('[取消任务] 响应成功: $success');
_logger.logWithLevel('✅ 取消任务成功');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
));
),
);
},
);
} catch (e) {
_logger.logWithLevel('❌ 取消任务异常: $e');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
),
);
}
}
@@ -187,64 +211,78 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
return;
}
emit(state.copyWith(
emit(
state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.pause,
));
),
);
try {
final userCubit = GetIt.I<AppUserCubit>();
final user = userCubit.state.user;
final siteCubit = GetIt.I<SiteCubit>();
final siteId = siteCubit.state.selectedSite?.id;
final selectedSite = siteCubit.state.selectedSite;
final siteId = selectedSite?.id;
final orgId = selectedSite?.orgId ?? 0;
if (user == null || siteId == null) {
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: '参数不完整',
operationType: DeviceTaskOperationType.none,
));
),
);
return;
}
final params2 = PauseTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
orgId: orgId,
siteId: siteId,
);
_logger.logWithLevel('[暂停任务] 请求: POST /iot/deviceTask/pauseTask');
_logger.logWithLevel('[暂停任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId');
_logger.logWithLevel(
'[暂停任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=$orgId, siteId=$siteId',
);
final result = await _pauseTaskUseCase.call(params2);
result.fold(
(failure) {
_logger.logWithLevel('[暂停任务] 响应失败: ${failure.message}');
_logger.logWithLevel('❌ 暂停任务失败: ${failure.message}');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
),
);
},
(success) {
_logger.logWithLevel('[暂停任务] 响应成功: $success');
_logger.logWithLevel('✅ 暂停任务成功');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
));
),
);
},
);
} catch (e) {
_logger.logWithLevel('❌ 暂停任务异常: $e');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
),
);
}
}
@@ -256,88 +294,111 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
return;
}
emit(state.copyWith(
emit(
state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.recovery,
));
),
);
try {
final userCubit = GetIt.I<AppUserCubit>();
final user = userCubit.state.user;
final siteCubit = GetIt.I<SiteCubit>();
final siteId = siteCubit.state.selectedSite?.id;
final selectedSite = siteCubit.state.selectedSite;
final siteId = selectedSite?.id;
final orgId = selectedSite?.orgId ?? 0;
if (user == null || siteId == null) {
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: '参数不完整',
operationType: DeviceTaskOperationType.none,
));
),
);
return;
}
final params3 = RecoveryTaskParams(
deviceId: deviceId,
taskId: taskId,
orgId: user.orgId ?? 0,
orgId: orgId,
siteId: siteId,
);
_logger.logWithLevel('[恢复任务] 请求: POST /iot/deviceTask/recoveryTask');
_logger.logWithLevel('[恢复任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=${user.orgId ?? 0}, siteId=$siteId');
_logger.logWithLevel(
'[恢复任务] 参数: deviceId=$deviceId, taskId=$taskId, orgId=$orgId, siteId=$siteId',
);
final result = await _recoveryTaskUseCase.call(params3);
result.fold(
(failure) {
_logger.logWithLevel('[恢复任务] 响应失败: ${failure.message}');
_logger.logWithLevel('❌ 恢复任务失败: ${failure.message}');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
),
);
},
(data) {
_logger.logWithLevel('[恢复任务] 响应成功: $data');
_logger.logWithLevel('✅ 恢复任务成功: $data');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
));
),
);
},
);
} catch (e) {
_logger.logWithLevel('❌ 恢复任务异常: $e');
emit(state.copyWith(
emit(
state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示错误弹窗
));
),
);
}
}
/// 更新当前任务ID(当选择新航线时调用)
void updateCurrentTaskId(int taskId) {
emit(state.copyWith(currentTaskId: taskId));
_logger.logWithLevel('🔄 更新当前任务ID: $taskId');
// 🔥 只有 activeTasks 非空时才设置 currentTask,避免信息栏一直显示
DeviceTaskEntity? taskEntity;
if (state.activeTasks.isNotEmpty) {
taskEntity = state.activeTasks.firstWhere(
(t) => t.id == taskId,
orElse: () => DeviceTaskEntity(
id: taskId,
deviceId: '',
taskStatus: 'EXECUTING',
taskStatusTranslate: '执行中',
),
);
}
emit(state.copyWith(currentTaskId: taskId, currentTask: taskEntity));
_logger.logWithLevel(
'🔄 更新当前任务ID: $taskId, activeTasks: ${state.activeTasks.length}, currentTask: ${taskEntity?.taskStatus ?? "null"}',
);
}
/// 🔥 手动选择任务(用户从弹窗中选择)
void selectTask(DeviceTaskEntity task) {
_logger.logWithLevel('✅ 用户选择任务ID: ${task.id}, 状态: ${task.taskStatus}');
emit(state.copyWith(
currentTask: task,
currentTaskId: task.id,
));
emit(state.copyWith(currentTask: task, currentTaskId: task.id));
}
/// 清除当前任务
void clearCurrentTask() {
emit(state.copyWith(
currentTask: null,
currentTaskId: null,
));
emit(state.copyWith(currentTask: null, currentTaskId: null));
_logger.logWithLevel('🧹 清除当前任务');
}

View File

@@ -16,6 +16,7 @@ import 'package:maibu_satabot_v2/features/devices/domain/usecases/update_devicen
import '../../../../core/consts/tcp_consts.dart';
import '../../../../core/logging/i_logger_service.dart';
import '../../../../core/logging/log_time.dart';
import '../../../../core/network/tcp/tcp_client.dart';
import '../../../../core/network/mqtt/domain/repositories/task_message_repository.dart';
import '../../data/models/device_add_path_point_model.dart';
@@ -268,6 +269,7 @@ class DevicesCubit extends Cubit<DevicesState> {
/// 切换设备
Future<void> switchDevice(DeviceEntity device) async {
debugPrint('${LogTime.now()} 👆 [DevicesCubit] 开始切换设备: ${device.deviceName}');
// 保持现有列表,只改 loading
emit(state.copyWith(isLoading: true));
@@ -276,6 +278,7 @@ class DevicesCubit extends Cubit<DevicesState> {
result.fold(
// 失败处理
(l) {
debugPrint('${LogTime.now()} ❌ [DevicesCubit] 切换设备失败: ${device.deviceName}, 原因: ${l.message}');
emit(state.copyWith(isLoading: false, errorMessage: l.message));
selectDevice(device);
},
@@ -288,7 +291,12 @@ class DevicesCubit extends Cubit<DevicesState> {
// 重置设备状态 Bloc,清除旧设备图表数据
_deviceStatusBloc.add(DeviceStatusReset());
debugPrint('✅ 新设备切换完成(HTTP-only,无新TCP连接): ${device.deviceName}');
// 🔥 机器状态数据源已切换为MQTT:用与TCP相同的方式获取设备号(deviceName),
// 订阅 mower/{sn}/property/* 主题(内部自动退订旧设备、同SN去重)
debugPrint('${LogTime.now()} 📡 [DevicesCubit] HTTP切换成功,开始MQTT订阅: ${device.deviceName}');
_deviceStatusBloc.startMqttRealtimeListening(device.deviceName);
debugPrint('${LogTime.now()} ✅ 新设备切换完成(HTTP-only,无新TCP连接): ${device.deviceName}');
emit(state.copyWith(isLoading: false, selectedDevice: device));
},
@@ -378,7 +386,7 @@ class DevicesCubit extends Cubit<DevicesState> {
emit(state.copyWith(isLoading: false, errorMessage: failure.message));
},
(records) {
// print('🔍 [DevicesCubit] 加载成功,记录数: ${records.length}');
print('🔍 [DevicesCubit] selectBySiteId 加载成功,记录数: ${records.length}');
// 将 WorkRecordEntity 转换为 Map<String, dynamic> 以兼容现有UI
final mappedRecords = records.map((record) {
// 🔥 核心修复:根据 jsonData 类型决定存储方式
@@ -390,14 +398,27 @@ class DevicesCubit extends Cubit<DevicesState> {
} else if (record.jsonData is WorkRecordJsonData) {
jsonDataStr = _workRecordJsonDataToJson(record.jsonData as WorkRecordJsonData);
}
return <String, dynamic>{
final mapped = <String, dynamic>{
'id': record.id.toString(),
'workName': record.workName,
'imgUrl': record.imgUrl ?? '',
'jsonData': jsonDataStr,
};
print(' ├─ [${record.workName}] id=${record.id}, jsonData长度=${jsonDataStr?.length ?? "null"}');
// 打印 jsonData 内部的 path/outer 信息
if (jsonDataStr != null && jsonDataStr.isNotEmpty) {
try {
final decoded = jsonDecode(jsonDataStr);
if (decoded is Map) {
print(' │ path类型=${decoded['path']?.runtimeType}, outer类型=${decoded['outer']?.runtimeType}, planModel=${decoded['planModel']}');
if (decoded['path'] is List) print(' │ path长度=${(decoded['path'] as List).length}');
if (decoded['outer'] is List) print(' │ outer长度=${(decoded['outer'] as List).length}');
}
} catch (_) {}
}
return mapped;
}).toList();
// print('🔍 [DevicesCubit] 转换后的数据: $mappedRecords');
print('🔍 [DevicesCubit] 转换完成,emit workRecords 数量: ${mappedRecords.length}');
emit(state.copyWith(isLoading: false, workRecords: mappedRecords));
},
);
@@ -439,15 +460,36 @@ class DevicesCubit extends Cubit<DevicesState> {
/// 加载指定作业名的路径数据
Future<void> loadSelectedPath(String workName) async {
emit(state.copyWith(isLoading: true));
debugPrint('📡 [loadSelectedPath] 开始查询, workName=$workName');
try {
final result = await _selectWorkRecordUseCase.call(workName);
result.fold(
(failure) => emit(
(failure) {
debugPrint('❌ [loadSelectedPath] 接口失败: ${failure.message}');
emit(
state.copyWith(isLoading: false, errorMessage: failure.message),
),
(data) => emit(state.copyWith(isLoading: false, pathData: data)),
);
},
(data) {
debugPrint('✅ [loadSelectedPath] 接口成功, 记录数: ${data.length}');
if (data.isNotEmpty) {
final first = data.first;
debugPrint(' ├─ 第一条记录 keys: ${first.keys.toList()}');
debugPrint(' ├─ path 类型: ${first['path']?.runtimeType}, 长度: ${(first['path'] as List?)?.length ?? "null"}');
debugPrint(' ├─ outer 类型: ${first['outer']?.runtimeType}, 长度: ${(first['outer'] as List?)?.length ?? "null"}');
debugPrint(' └─ planModel: ${first['planModel']}');
if (first['path'] is List && (first['path'] as List).isNotEmpty) {
debugPrint(' └─ path[0]: ${(first['path'] as List).first}');
}
if (first['outer'] is List && (first['outer'] as List).isNotEmpty) {
debugPrint(' └─ outer[0]: ${(first['outer'] as List).first}');
}
}
emit(state.copyWith(isLoading: false, pathData: data));
},
);
} catch (e) {
debugPrint('❌ [loadSelectedPath] 异常: $e');
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
/// ESRI World Imagery 底图地址(国内网络可能不可达)
const String kEsriWorldImageryUrl =
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
/// 网络超时时长(瓦片加载)
const Duration _kTileTimeout = Duration(seconds: 8);
/// ─────────────────────────────────────────────────────────
/// 容错网络瓦片图
/// ─────────────────────────────────────────────────────────
///
/// 加载失败(超时 / 断网 / 404 等)时返回 1×1 透明 PNG,
/// 避免 SocketException 刷屏并保证地图其余部分正常渲染。
class ResilientNetworkImage extends ImageProvider<ResilientNetworkImage> {
const ResilientNetworkImage(this.url);
final String url;
@override
Future<ResilientNetworkImage> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<ResilientNetworkImage>(this);
@override
ImageStreamCompleter loadImage(
ResilientNetworkImage key,
ImageDecoderCallback decode,
) {
return MultiFrameImageStreamCompleter(
codec: _loadAsync(decode),
scale: 1.0,
debugLabel: 'ResilientNetworkImage(${url.split('/').last})',
);
}
Future<ui.Codec> _loadAsync(ImageDecoderCallback decode) async {
try {
final client = http.Client();
final response = await client
.get(Uri.parse(url))
.timeout(_kTileTimeout);
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
final buffer = await ui.ImmutableBuffer.fromUint8List(
response.bodyBytes,
);
return decode(buffer);
}
} catch (_) {
// 超时 / 网络异常 / 解码失败 → 走下面的透明兜底
}
// 1×1 透明 PNG,避免 Flutter 框架抛出 MissingImageException
return decode(
await ui.ImmutableBuffer.fromUint8List(_transparentPng),
);
}
@override
bool operator ==(Object other) =>
other is ResilientNetworkImage && other.url == url;
@override
int get hashCode => url.hashCode;
}
/// ─────────────────────────────────────────────────────────
/// 容错 TileProvider —— 直接在 TileLayer.tileProvider 中使用
/// ─────────────────────────────────────────────────────────
///
/// ```dart
/// TileLayer(
/// urlTemplate: kEsriWorldImageryUrl,
/// tileProvider: ResilientTileProvider(),
/// minZoom: 0,
/// maxZoom: 19,
/// )
/// ```
class ResilientTileProvider extends TileProvider {
@override
ImageProvider<Object> getImage(TileCoordinates coordinates, TileLayer options) {
final url = getTileUrl(coordinates, options);
return ResilientNetworkImage(url);
}
}
/// 1×1 透明 PNG 字节(兜底用)
final Uint8List _transparentPng = Uint8List.fromList(<int>[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1×1
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, // RGBA 8bit
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, // IDAT chunk
0x54, 0x78, 0x9C, 0x62, 0x00, 0x00, 0x00, 0x02,
0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00, // compressed data
0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, // IEND chunk
0x60, 0x82,
]);

File diff suppressed because it is too large Load Diff

View File

@@ -187,8 +187,9 @@ class _VerticalFloatMenuState extends State<VerticalFloatMenu> {
Text(loc.translate('route_planning.select_work_mode'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 20),
_modeItem(
title: loc.translate('route_planning.bow_mode'),
subtitle: loc.translate('route_planning.bow_mode_subtitle'),
// 🔥 硬编码:差包更新不更新 assets,必须写死文案
title: '覆盖模式',
subtitle: '标准全覆盖路径规划',
isSelected: selectedMode == WorkMode.bow,
onTap: () {
setState(() {
@@ -198,8 +199,8 @@ class _VerticalFloatMenuState extends State<VerticalFloatMenu> {
),
const SizedBox(height: 12),
_modeItem(
title: loc.translate('route_planning.custom_mode'),
subtitle: loc.translate('route_planning.custom_mode_subtitle'),
title: '自定义模式',
subtitle: '手动设定作业区域',
isSelected: selectedMode == WorkMode.custom,
onTap: () {
setState(() {

View File

@@ -8,6 +8,7 @@ import 'package:maibu_satabot_v2/features/my/repository/my_repository.dart';
import 'dart:convert';
import '../../../../core/di/injection.dart';
import '../../../../core/consts/http_api_consts.dart';
import '../../../../core/storage/user_storage.dart';
// 必须导入 DeviceFailure 类
import '../../../../features/devices/domain/errors/device_failure.dart';
@@ -26,7 +27,7 @@ class MyRepositoryImpl implements MyRepository {
// 核心修复:严格匹配抽象类的方法签名
@override
Future<Either<DeviceFailure, int>> updateName(String nickName) async {
final url = Uri.parse('http://1.95.137.212:59003/system/user/profile');
final url = Uri.parse('${HttpApiConsts.baseUrl}/system/user/profile');
// 补充:从 UserStorage 获取 token(接口通常需要认证)
final token = await _getToken();

View File

@@ -1029,6 +1029,10 @@ class RemoteControlCubit extends Cubit<RemoteControlState> {
emit(state.copyWith(targetDevice: device));
debugPrint('✅ [RemoteControl] targetDevice状态已更新');
// 🔥 MQTT机器状态订阅:切换设备时订阅新设备的topic(与DevicesCubit.switchDevice相同逻辑)
debugPrint('📡 [RemoteControl] 开始MQTT机器状态订阅: ${device.deviceName}');
deviceStatusBloc.startMqttRealtimeListening(device.deviceName);
// 🔥 关键修复:TCP 已在登录时建立,设备切换只用 HTTP
// 不再创建新 TCP 连接,避免重复发送 0x03 触发服务端推送 have_logged_in
if (tcpClient.isConnected) {

View File

@@ -12,7 +12,6 @@ import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
import '../../../../core/app/app_user_cubit.dart';
import '../../../../core/router/route_paths.dart';
import '../../../auth/presentation/bloc/auth_cubit.dart'; // 🔥 导入 AuthCubit
import '../../../devices/domain/entities/device_entity.dart';
import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../bloc/remote_control_cubit.dart';
import '../bloc/remote_control_state.dart';
@@ -116,9 +115,6 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
return;
}
// 🔥 弹窗显示当前控制的设备信息
_showTargetDeviceDialog(context, remoteCubit.state.targetDevice!);
// 🔥 只在控制循环未启动时才启动
remoteCubit.startControlLoop();
// debugPrint('✅ [RemoteControl] 控制循环已启动');
@@ -539,36 +535,6 @@ class _RemoteControlPageState extends State<RemoteControlPage> {
);
}
/// 🔥 显示当前控制设备的弹窗
void _showTargetDeviceDialog(BuildContext context, DeviceEntity device) {
showDialog(
context: context,
barrierDismissible: true,
builder: (ctx) => AlertDialog(
title: const Text('🎮 远程控制'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'设备名称: ${device.deviceName}',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 8),
Text(
'设备ID: ${device.deviceName}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('确定'),
),
],
),
);
}
/// 🔥 显示当前控制设备的弹窗 - 已移除:进入远程遥控页不再弹出设备确认弹窗
}

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../../../../../core/consts/http_api_consts.dart';
import '../../domain/entities/device_entity.dart';
import '../../domain/entities/device_status_entity.dart';
@@ -12,6 +13,7 @@ abstract class DeviceRemoteDataSource {
int? siteId,
String? typeFilter,
});
Future<List<DeviceDataModel>> getAllDevices();
}
/// 设备远程数据源实现类 - 从真实 API 获取数据
@@ -64,6 +66,37 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
}
}
@override
Future<List<DeviceDataModel>> getAllDevices() async {
try {
final response = await dio.get(
HttpApiConsts.getDeviceList,
queryParameters: {'pageNum': 1, 'pageSize': 9999},
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final responseData = response.data;
if (responseData['code'] != 200) {
throw Exception(responseData['msg'] ?? '业务异常');
}
final List<dynamic> rows = responseData['rows'] ?? [];
debugPrint(
'>>> [DeviceRemoteDataSource] getAllDevices 返回 ${rows.length} 条设备',
);
for (int i = 0; i < rows.length && i < 10; i++) {
debugPrint('>>> [DeviceRemoteDataSource] rows[$i]: ${rows[i]}');
}
return rows.map((item) => _parseDeviceFromJson(item)).toList();
} catch (e) {
debugPrint('>>> [DeviceRemoteDataSource] getAllDevices error: $e');
rethrow;
}
}
Future<List<DeviceDataModel>> _fetchDevicesFromAPI(int siteId) async {
final response = await dio.get(
HttpApiConsts.getSiteDeviceList,
@@ -107,10 +140,19 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
DeviceDataModel _parseDeviceFromJson(Map<String, dynamic> json) {
// 在线状态:使用 onlineStatus 字段(1=在线, 0=离线)
final onlineStatus = json['onlineStatus'] ?? 0;
final deviceId = json['deviceId']?.toString() ?? '';
final name = json['deviceName'] ?? json['name'] ?? '';
final type = json['deviceTypeName'] ?? json['type'] ?? '未知设备';
final isRobot = _isRobotDevice(type, name, deviceId);
debugPrint(
'🔍 [DeviceListDataSource] deviceId=$deviceId name=$name type=$type isRobot=$isRobot',
);
return DeviceDataModel(
deviceId: json['deviceId']?.toString() ?? '',
name: json['deviceName'] ?? json['name'] ?? '',
type: json['deviceTypeName'] ?? json['type'] ?? '未知设备',
deviceId: deviceId,
name: name,
type: type,
status: onlineStatus == 1 ? '在线' : '离线',
power: (json['power'] as num?)?.toDouble() ?? 0,
todayEnergy: (json['todayEnergy'] as num?)?.toDouble() ?? 0,
@@ -118,6 +160,7 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
current: (json['current'] as num?)?.toDouble(),
voltage: (json['voltage'] as num?)?.toDouble(),
radiation: (json['radiation'] as num?)?.toDouble(),
isRobot: isRobot,
);
}
@@ -131,9 +174,32 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
power: (json['capacity_percent'] as num?)?.toDouble() ?? 0,
todayEnergy: 0,
temperature: (json['environment_temperature'] as num?)?.toDouble(),
isRobot: false,
);
}
/// 🔥 通用判断:是否机器人(型号前缀 + 中文关键字双重匹配,新型号也能自动识别)
bool _isRobotDevice(String type, String name, String deviceId) {
final haystacks = [
type.toLowerCase(),
name.toLowerCase(),
deviceId.toLowerCase(),
];
// 型号家族前缀(MC700/MC700STD/MC700PLUS/MC700AIR/MC700PRO 等;RCHWD/RCHETD 等)
const prefixes = ['mc', 'rch', 'rcetd', 'rchetd'];
// 中文类型关键字
const keywords = ['除草', '巡检', '清洗', '割草', '机器人'];
for (final s in haystacks) {
for (final p in prefixes) {
if (s.startsWith(p)) return true;
}
}
for (final k in keywords) {
if (type.contains(k) || name.contains(k)) return true;
}
return false;
}
String _getStatusText(int status) {
switch (status) {
case 1:

View File

@@ -12,6 +12,7 @@ class DeviceDataModel {
final double? current;
final double? voltage;
final double? radiation;
final bool isRobot;
const DeviceDataModel({
required this.deviceId,
@@ -24,6 +25,7 @@ class DeviceDataModel {
this.current,
this.voltage,
this.radiation,
this.isRobot = false,
});
/// 从 JSON 创建数据模型
@@ -71,6 +73,7 @@ class DeviceDataModel {
current: current,
voltage: voltage,
radiation: radiation,
isRobot: isRobot,
);
}
}

View File

@@ -20,4 +20,10 @@ class DeviceRepositoryImpl implements DeviceRepository {
final dataModels = await remoteDataSource.getDeviceList(siteId: siteId, typeFilter: typeFilter);
return dataModels.map((model) => model.toEntity()).toList();
}
@override
Future<List<DeviceEntity>> getAllDevices() async {
final dataModels = await remoteDataSource.getAllDevices();
return dataModels.map((model) => model.toEntity()).toList();
}
}

View File

@@ -10,6 +10,7 @@ class DeviceEntity {
final double? current;
final double? voltage;
final double? radiation;
final bool isRobot;
const DeviceEntity({
required this.deviceId,
@@ -22,5 +23,6 @@ class DeviceEntity {
this.current,
this.voltage,
this.radiation,
this.isRobot = false,
});
}

View File

@@ -5,4 +5,5 @@ import '../entities/device_status_entity.dart';
abstract class DeviceRepository {
Future<DeviceStatusEntity> getDeviceStatus();
Future<List<DeviceEntity>> getDeviceList({int? siteId, String? typeFilter});
Future<List<DeviceEntity>> getAllDevices();
}

View File

@@ -0,0 +1,13 @@
import '../entities/device_entity.dart';
import '../repositories/device_repository.dart';
/// 获取全部设备列表用例(用于蓝牙绑定匹配)
class GetAllDevicesUseCase {
final DeviceRepository repository;
const GetAllDevicesUseCase({required this.repository});
Future<List<DeviceEntity>> execute() async {
return await repository.getAllDevices();
}
}

View File

@@ -10,15 +10,15 @@ class FloatBarSettingService {
/// 🔥 静态实例引用
static FloatBarSettingService? _instance;
/// 🔥 静态状态变量 - 所有组件共享
static bool _isEnabled = true;
/// 🔥 静态状态变量 - 所有组件共享(默认关闭)
static bool _isEnabled = false;
/// 🔥 静态 ValueNotifier - 用于通知UI变化
static final ValueNotifier<bool> _settingNotifier = ValueNotifier<bool>(true);
/// 🔥 静态 ValueNotifier - 用于通知UI变化(默认关闭)
static final ValueNotifier<bool> _settingNotifier = ValueNotifier<bool>(false);
FloatBarSettingService(this._prefs) {
_instance = this;
_isEnabled = _prefs.getBool(_key) ?? true;
_isEnabled = _prefs.getBool(_key) ?? false;
_settingNotifier.value = _isEnabled;
debugPrint('✅ [FloatBarSettingService] 初始化完成,初始状态: $_isEnabled');
}

View File

@@ -23,7 +23,7 @@ import '../../../../../../core/network/mqtt/data/datasources/drone_osd_datasourc
enum SimulationMode {
circle, // 圆形路径
polygon, // 多边形路径(六边形)
bow, // 弓字形路径(Zigzag)
bow, // 覆盖模式路径(Coverage)
rectangle, // 矩形路径
}
@@ -524,7 +524,7 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
return path;
}
/// 生成弓字形路径(Zigzag)
/// 生成覆盖模式路径(Coverage)
List<LatLng> _generateBowPath(double centerLat, double centerLng, int points) {
final List<LatLng> path = [];
const double width = 0.004; // 宽度约400米
@@ -544,7 +544,7 @@ class FloatBarWidgetState extends State<FloatBarWidget> {
}
}
debugPrint('〰️ 生成弓字形路径: ${path.length}个点, $rows行');
debugPrint('〰️ 生成覆盖模式路径: ${path.length}个点, $rows行');
return path;
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
import 'dart:developer' as developer;
import 'dart:typed_data';
@@ -11,6 +12,9 @@ import '../../../../../core/bluetooth/protocol_parser.dart';
import '../../../../../core/protocol/machine_protocol_constants.dart';
import 'package:get_it/get_it.dart';
import '../../../../../core/services/device_permission_service.dart';
import '../../domain/entities/device_entity.dart';
import '../../domain/usecases/get_all_devices_usecase.dart';
import 'bind_device_page.dart';
class BleDeviceDetailPage extends StatefulWidget {
final BluetoothDevice device;
@@ -34,6 +38,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
StreamSubscription<BluetoothDevice?>? _connectingSubscription;
StreamSubscription<BlePacket>? _packetSubscription;
final List<_ReceivedPacket> _receivedPackets = [];
/// 按命令类型分组的最新推送:command → 最新数据
final Map<int, _ReceivedPacket> _latestPushByCommand = {};
bool _pushExpanded = false; // 数据推送默认折叠
@@ -44,6 +49,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
/// 等待设备返回 0x06 写配置应答
Completer<bool>? _writeRespCompleter;
/// 等待回读 0x05 应答,携带期望字段值用于对比
Completer<Uint8List?>? _readbackCompleter;
Map<String, String>? _expectedFieldValues;
@@ -247,6 +253,107 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
await BleManager.instance.disconnect();
}
/// 获取当前蓝牙设备的显示名称
String get _bleDeviceName {
final advData = widget.scanResult?.advertisementData;
return _resolvedName ??
(advData?.advName.isNotEmpty == true
? advData!.advName
: (widget.device.platformName.isNotEmpty
? widget.device.platformName
: (advData?.localName?.isNotEmpty == true
? advData!.localName!
: (widget.device.advName.isNotEmpty
? widget.device.advName
: ''))));
}
/// 绑定设备:调接口获取设备列表,用蓝牙名(时间戳)匹配后跳转绑定页
Future<void> _bindDevice() async {
final bleName = _bleDeviceName;
if (bleName.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法获取蓝牙设备名称')));
return;
}
// 显示 loading
BuildContext? loadingCtx;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) {
loadingCtx = ctx;
return const Center(child: CircularProgressIndicator());
},
);
try {
final getAllDevicesUseCase = GetIt.instance<GetAllDevicesUseCase>();
final devices = await getAllDevicesUseCase.execute();
if (loadingCtx != null && Navigator.canPop(loadingCtx!)) {
Navigator.pop(loadingCtx!);
}
debugPrint(
'>>> [BindDevice] bleName=$bleName, devices=${devices.length}',
);
for (int i = 0; i < devices.length && i < 10; i++) {
debugPrint(
'>>> [BindDevice] device[$i]: id=${devices[i].deviceId}, name=${devices[i].name}',
);
}
// 用蓝牙名(时间戳)匹配设备名中包含该时间戳的设备
DeviceEntity? matchedDevice;
for (final device in devices) {
if (device.name.contains(bleName)) {
matchedDevice = device;
break;
}
}
if (matchedDevice == null) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('未找到名称包含「$bleName」的设备')));
}
return;
}
if (matchedDevice.deviceId.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('匹配到的设备缺少设备ID')));
}
return;
}
if (!mounted) return;
// 复用扫一扫的绑定页面流程
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BindDevicePage(scanResult: matchedDevice!.deviceId),
),
);
} catch (e) {
if (loadingCtx != null && Navigator.canPop(loadingCtx!)) {
Navigator.pop(loadingCtx!);
}
debugPrint('>>> [BindDevice] error: $e');
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('绑定设备失败: $e')));
}
}
}
@override
void dispose() {
_connSubscription?.cancel();
@@ -372,7 +479,6 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
await _sendAndLog(MachineProtocolConstants.cmdReadConfig, []);
}
String _bytesToHex(List<int> bytes) {
if (bytes.isEmpty) return '';
return bytes
@@ -382,16 +488,26 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
String _commandName(int command) {
switch (command) {
case MachineProtocolConstants.cmdRemoteControl: return '远程遥控';
case MachineProtocolConstants.cmdPathPlanning: return '路径规划';
case MachineProtocolConstants.cmdStatusInfo: return '状态信息';
case MachineProtocolConstants.cmdGetId: return '查询ID';
case MachineProtocolConstants.cmdGetAuth: return '获取授权';
case MachineProtocolConstants.cmdReadConfig: return '读配置';
case MachineProtocolConstants.cmdWriteConfig: return '写配置';
case MachineProtocolConstants.cmdObstacleAvoid: return '避障';
case MachineProtocolConstants.cmdHeartbeat: return '心跳包';
default: return '未知(0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')})';
case MachineProtocolConstants.cmdRemoteControl:
return '远程遥控';
case MachineProtocolConstants.cmdPathPlanning:
return '路径规划';
case MachineProtocolConstants.cmdStatusInfo:
return '状态信息';
case MachineProtocolConstants.cmdGetId:
return '查询ID';
case MachineProtocolConstants.cmdGetAuth:
return '获取授权';
case MachineProtocolConstants.cmdReadConfig:
return '读配置';
case MachineProtocolConstants.cmdWriteConfig:
return '写配置';
case MachineProtocolConstants.cmdObstacleAvoid:
return '避障';
case MachineProtocolConstants.cmdHeartbeat:
return '心跳包';
default:
return '未知(0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')})';
}
}
@@ -586,13 +702,19 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
width: 80,
child: Text(
label,
style: TextStyle(fontSize: 13, color: context.appColors.textTertiary),
style: TextStyle(
fontSize: 13,
color: context.appColors.textTertiary,
),
),
),
Expanded(
child: Text(
value,
style: TextStyle(fontSize: 13, color: context.appColors.textPrimary),
style: TextStyle(
fontSize: 13,
color: context.appColors.textPrimary,
),
),
),
],
@@ -626,8 +748,10 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
Row(
children: [
// 连接/断开 按钮
Expanded(
child: ElevatedButton(
onPressed: _isConnecting
? null
@@ -654,6 +778,37 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
: Text(_isConnected ? '断开连接' : '连接设备'),
),
),
const SizedBox(width: 12),
// 绑定设备 按钮(未连接时灰色禁用)
Expanded(
child: ElevatedButton(
onPressed: _isConnected ? _bindDevice : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: _isConnected
? context.appColors.primary
: context.appColors.fillBackground,
foregroundColor: _isConnected
? Colors.white
: context.appColors.textTertiary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
'绑定设备',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: _isConnected
? Colors.white
: context.appColors.textTertiary,
),
),
),
),
],
),
],
),
);
@@ -785,7 +940,10 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
),
child: Text(
p,
style: TextStyle(fontSize: 10, color: context.appColors.primary),
style: TextStyle(
fontSize: 10,
color: context.appColors.primary,
),
),
),
)
@@ -871,14 +1029,21 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
if (pushes.isNotEmpty) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: context.appColors.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${pushes.length}',
style: const TextStyle(fontSize: 11, color: Colors.white, fontWeight: FontWeight.w600),
style: const TextStyle(
fontSize: 11,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
@@ -894,7 +1059,10 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
},
child: Text(
'清空',
style: TextStyle(fontSize: 13, color: context.appColors.primary),
style: TextStyle(
fontSize: 13,
color: context.appColors.primary,
),
),
),
],
@@ -906,16 +1074,23 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
child: Center(
child: Text(
'等待接收数据...',
style: TextStyle(fontSize: 13, color: context.appColors.textTertiary),
style: TextStyle(
fontSize: 13,
color: context.appColors.textTertiary,
),
),
),
)
else
Column(
children: pushes.map((p) => Padding(
children: pushes
.map(
(p) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildPushCard(p),
)).toList(),
),
)
.toList(),
),
],
),
@@ -970,13 +1145,17 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
'${p.time.hour.toString().padLeft(2, '0')}:'
'${p.time.minute.toString().padLeft(2, '0')}:'
'${p.time.second.toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 11, color: context.appColors.textTertiary),
style: TextStyle(
fontSize: 11,
color: context.appColors.textTertiary,
),
),
],
),
if (hasParsed) ...[
const SizedBox(height: 8),
...p.parsedFields!.map((f) => Padding(
...p.parsedFields!.map(
(f) => Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -985,7 +1164,10 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
width: 68,
child: Text(
f.label,
style: TextStyle(fontSize: 11, color: context.appColors.textTertiary),
style: TextStyle(
fontSize: 11,
color: context.appColors.textTertiary,
),
),
),
Expanded(
@@ -993,12 +1175,16 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
f.value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11, color: context.appColors.textPrimary),
style: TextStyle(
fontSize: 11,
color: context.appColors.textPrimary,
),
),
),
],
),
)),
),
),
] else if (p.hex.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
@@ -1019,12 +1205,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
}
/// 可编辑的字段白名单:仅这四个增益属性可修改
static const _editableFields = {
'左轮前进增益',
'左轮后退增益',
'右轮前进增益',
'右轮后退增益',
};
static const _editableFields = {'左轮前进增益', '左轮后退增益', '右轮前进增益', '右轮后退增益'};
bool _isEditableField(String label) => _editableFields.contains(label);
@@ -1156,7 +1337,9 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
),
TextSpan(
text: ' | ',
style: TextStyle(color: context.appColors.divider),
style: TextStyle(
color: context.appColors.divider,
),
),
TextSpan(
text: mod.newValue,
@@ -1219,7 +1402,10 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
hintText: '输入新值',
border: const OutlineInputBorder(),
suffixText: '当前: $currentValue',
suffixStyle: TextStyle(fontSize: 11, color: context.appColors.textTertiary),
suffixStyle: TextStyle(
fontSize: 11,
color: context.appColors.textTertiary,
),
errorText: errorText,
),
),
@@ -1236,7 +1422,9 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
Navigator.pop(ctx);
return;
}
final pkt = _latestPushByCommand[MachineProtocolConstants.cmdReadConfig];
final pkt =
_latestPushByCommand[MachineProtocolConstants
.cmdReadConfig];
final entity = pkt?.configEntity;
if (entity == null) {
Navigator.pop(ctx);
@@ -1269,7 +1457,8 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
try {
// 🔐 前置权限校验:使用读配置(0x05)中的芯片序列号(chipUid)作为设备ID
final readConfigPacket = _latestPushByCommand[MachineProtocolConstants.cmdReadConfig];
final readConfigPacket =
_latestPushByCommand[MachineProtocolConstants.cmdReadConfig];
final chipUid = readConfigPacket?.configEntity?.chipUid ?? '';
if (chipUid.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1305,7 +1494,11 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
builder: (dialogCtx) => AlertDialog(
title: Row(
children: [
Icon(Icons.check_circle, color: context.appColors.success, size: 28),
Icon(
Icons.check_circle,
color: context.appColors.success,
size: 28,
),
const SizedBox(width: 8),
const Text('权限通过'),
],
@@ -1343,7 +1536,10 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
// 2. 发送 0x06 并等待设备应答
_writeRespCompleter = Completer<bool>();
developer.log('[WriteConfig] ⏳ 发送 0x06, 等待应答...', name: 'BleDeviceDetail');
developer.log(
'[WriteConfig] ⏳ 发送 0x06, 等待应答...',
name: 'BleDeviceDetail',
);
await BleManager.instance.sendCommand(
MachineProtocolConstants.cmdWriteConfig,
configBytes.toList(),
@@ -1363,7 +1559,9 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
if (!mounted) return;
// 3. 记录期望值,自动回读对比(无论是否收到显式确认都回读)
_expectedFieldValues = Map<String, String>.from(p.configEntity!.toFieldMap());
_expectedFieldValues = Map<String, String>.from(
p.configEntity!.toFieldMap(),
);
// 4. 自动回读配置
_readbackCompleter = Completer<Uint8List?>();
@@ -1430,14 +1628,13 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
rawPayload: readbackPayload,
configEntity: newEntity,
);
_latestPushByCommand[MachineProtocolConstants.cmdReadConfig] = updatedEntry;
_latestPushByCommand[MachineProtocolConstants.cmdReadConfig] =
updatedEntry;
});
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'校验不一致: ${mismatched.join('、')},已恢复为设备实际值',
),
content: Text('校验不一致: ${mismatched.join('、')},已恢复为设备实际值'),
backgroundColor: context.appColors.warning,
duration: const Duration(seconds: 3),
),
@@ -1462,7 +1659,8 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
rawPayload: readbackPayload,
configEntity: newEntity,
);
_latestPushByCommand[MachineProtocolConstants.cmdReadConfig] = updatedEntry;
_latestPushByCommand[MachineProtocolConstants.cmdReadConfig] =
updatedEntry;
_configModifications = {};
});
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -119,15 +119,29 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
}
}
/// 🔥 机器人设备名称前缀,用于从通用设备列表中过滤掉机器人
static const _robotPrefixes = [
'RCHETD-CN',
'MC700PLUS-CN',
'MC700-CN',
'MC700PRO-CN',
'RCETD-CN',
'MC700AIR-CN',
/// 🔥 机器人型号家族前缀(小写,startsWith 匹配,覆盖 MC700/MC700STD/MC700PLUS/MCAIR/RCHWD/RCHETD 等全系列)
static const _robotPrefixes = ['mc', 'rch', 'rcetd', 'rchetd'];
/// 🔥 机器人类型中文名关键字(接口 deviceTypeName 可能是纯中文)
static const _robotTypeKeywords = ['除草', '巡检', '清洗', '割草', '机器人'];
/// 🔥 通用判断:是否机器人(型号前缀 + 中文关键字双重匹配,新型号也能自动识别)
static bool _isRobotDevice(String type, String name, String deviceId) {
final haystacks = [
type.toLowerCase(),
name.toLowerCase(),
deviceId.toLowerCase(),
];
for (final s in haystacks) {
for (final p in _robotPrefixes) {
if (s.startsWith(p)) return true;
}
}
for (final k in _robotTypeKeywords) {
if (type.contains(k) || name.contains(k)) return true;
}
return false;
}
@override
Widget build(BuildContext context) {
@@ -186,7 +200,11 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
children: [
const Flexible(child: SiteSelectorWidget(compact: true)),
const SizedBox(width: 8),
Container(width: 1, height: 20, color: context.appColors.divider),
Container(
width: 1,
height: 20,
color: context.appColors.divider,
),
const SizedBox(width: 8),
Text(
AppLocalizations.of(
@@ -310,7 +328,10 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
vertical: 12,
),
),
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
),
onSubmitted: (value) {
context.read<DeviceListBloc.DeviceStatusBloc>().add(
DeviceListEvent.DeviceStatusSearch(value),
@@ -490,13 +511,16 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
} else {
// 🔥 "全部"模式下,排除已由专用组件渲染的类型,避免重复展示
// drone_station 已通过 _buildDroneStationList 渲染
// 机器人(通过名称前缀识别)已通过 _buildEmbeddedRobotList 渲染
// 机器人通过下面的 _isRobotDevice 通用匹配识别,已由 _buildEmbeddedRobotList 渲染
filteredByType = state.devices.where((device) {
if (device.type == 'drone_station') return false;
final isRobot = _robotPrefixes.any(
(prefix) =>
device.name.startsWith(prefix) ||
device.deviceId.startsWith(prefix),
final isRobot = _isRobotDevice(
device.type,
device.name,
device.deviceId,
);
debugPrint(
'🔍 [全部-过滤] deviceId=${device.deviceId} type=${device.type} name=${device.name} → isRobot=$isRobot ${isRobot ? '✅排除' : ''}',
);
return !isRobot;
}).toList();
@@ -722,7 +746,9 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
builder: (context, state) {
if (state is DroneStationLoading) {
return Center(
child: CircularProgressIndicator(color: context.appColors.primary),
child: CircularProgressIndicator(
color: context.appColors.primary,
),
);
}
@@ -1042,9 +1068,8 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
debugPrint('🔍 [DeviceStatusPage] 设备ID: $deviceId');
debugPrint('🔍 [DeviceStatusPage] 设备类型: $deviceType');
// 🔥 判断是否为机器人(根据设备类型前缀)
final robotPrefixes = _robotPrefixes;
bool isRobot = robotPrefixes.any((prefix) => deviceType.startsWith(prefix));
// 🔥 判断是否为机器人:用通用匹配(型号前缀 + 中文关键字)
bool isRobot = _isRobotDevice(deviceType, deviceName, deviceId);
// 🔥 判断是否为无人机机场(根据设备类型包含关键字)
final droneStationKeywords = [
@@ -1065,22 +1090,8 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
(deviceType.isEmpty || deviceType == '未知设备' || deviceType == '未知类型')) {
debugPrint('⚠️ [DeviceStatusPage] type 字段无效,使用设备名称进行二次判断');
// 检查每个机器人前缀是否匹配
for (final prefix in robotPrefixes) {
final nameMatch = deviceName.startsWith(prefix);
final idMatch = deviceId.startsWith(prefix);
if (nameMatch || idMatch) {
debugPrint(
'🔍 [DeviceStatusPage] 机器人前缀匹配: "$prefix" -> 名称匹配=$nameMatch, ID匹配=$idMatch',
);
}
}
// 根据设备名称前缀判断机器人
isRobot = robotPrefixes.any(
(prefix) =>
deviceName.startsWith(prefix) || deviceId.startsWith(prefix),
);
// 根据通用匹配重新判断(已统一用 lowercase)
isRobot = _isRobotDevice(deviceType, deviceName, deviceId);
// 检查每个无人机关键字是否匹配
for (final keyword in droneStationKeywords) {

View File

@@ -1460,7 +1460,7 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
try {
final dio = DioClient.create();
final response = await dio.post(
'http://1.95.137.212:59003/iot/UAV/flightTaskCommand',
'${HttpApiConsts.baseUrl}/iot/UAV/flightTaskCommand',
data: {'command': command, 'deviceSn': widget.droneSn},
);

View File

@@ -21,6 +21,7 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
int _connectingCountdown = 15;
Timer? _connectingTimer;
bool _isStartingScan = false;
String _searchQuery = '';
StreamSubscription<BluetoothAdapterState>? _stateSubscription;
StreamSubscription<List<ScanResult>>? _scanSubscription;
@@ -123,7 +124,7 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
await BleManager.instance.openBluetooth();
}
/// 排序:已连接设备排第一,有名称设备优先
/// 排序:已连接设备排第一,纯数字名称(时间戳样式)置顶,有名称设备优先
List<ScanResult> _sortResults(List<ScanResult> results) {
final mgrConnected = BleManager.instance.connectedDevice;
final sorted = List<ScanResult>.from(results);
@@ -133,7 +134,12 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
final bConnected = b.device.remoteId == mgrConnected?.remoteId;
if (aConnected && !bConnected) return -1;
if (!aConnected && bConnected) return 1;
// 第二级:有名称的排前面
// 第二级:纯数字名称(时间戳样式,如 17xxxx)排前面
final aIsNumeric = _isNumericName(a);
final bIsNumeric = _isNumericName(b);
if (aIsNumeric && !bIsNumeric) return -1;
if (!aIsNumeric && bIsNumeric) return 1;
// 第三级:有名称的排前面
final aHasName = _hasDeviceName(a);
final bHasName = _hasDeviceName(b);
if (aHasName && !bHasName) return -1;
@@ -152,6 +158,27 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
r.device.advName.isNotEmpty;
}
/// 获取设备显示名称
String _getDeviceName(ScanResult r) {
final advData = r.advertisementData;
return advData.advName.isNotEmpty
? advData.advName
: (r.device.platformName.isNotEmpty
? r.device.platformName
: (advData.localName?.isNotEmpty == true
? advData.localName!
: (r.device.advName.isNotEmpty
? r.device.advName
: '${r.device.remoteId}')));
}
/// 判断设备名称是否为纯数字(时间戳样式,如 1777272534998)
bool _isNumericName(ScanResult r) {
final name = _getDeviceName(r);
if (name.isEmpty) return false;
return RegExp(r'^\d+$').hasMatch(name);
}
/// 从详情页返回时同步连接状态(包括连接中和已连接)
void _syncConnectedDevice() {
final mgrConnected = BleManager.instance.connectedDevice;
@@ -448,12 +475,22 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
Widget _buildDeviceList() {
// 过滤掉已连接设备(它已在状态卡片下方单独显示)
final otherResults = _connectedDevice != null
var otherResults = _connectedDevice != null
? _scanResults
.where((r) => r.device.remoteId != _connectedDevice!.remoteId)
.toList()
: _scanResults;
// 按搜索关键词过滤
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
otherResults = otherResults.where((r) {
final name = _getDeviceName(r).toLowerCase();
final mac = r.device.remoteId.toString().toLowerCase();
return name.contains(query) || mac.contains(query);
}).toList();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -466,6 +503,53 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
),
),
const SizedBox(height: 12),
// 搜索框
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFFF2F3F5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.search, size: 18, color: Color(0xFF86909C)),
const SizedBox(width: 8),
Expanded(
child: TextField(
onChanged: (value) {
setState(() => _searchQuery = value);
},
decoration: const InputDecoration(
hintText: '搜索设备名称或MAC',
hintStyle: TextStyle(
fontSize: 13,
color: Color(0xFF86909C),
),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 10),
),
style: const TextStyle(
fontSize: 13,
color: Color(0xFF1D2129),
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
setState(() => _searchQuery = '');
},
child: const Icon(
Icons.close,
size: 16,
color: Color(0xFF86909C),
),
),
],
),
),
if (!_isBluetoothOn)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
@@ -487,20 +571,20 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
),
)
else if (otherResults.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Column(
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
child: const CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(height: 12),
const SizedBox(height: 12),
Text(
'正在扫描附近设备...',
style: TextStyle(color: Color(0xFF86909C)),
_searchQuery.isNotEmpty ? '未找到匹配的设备' : '正在扫描附近设备...',
style: const TextStyle(color: Color(0xFF86909C)),
),
],
),
@@ -581,10 +665,7 @@ class _BluetoothScanModalState extends State<BluetoothScanModal> {
),
child: const Text(
'已连接',
style: TextStyle(
fontSize: 10,
color: Colors.white,
),
style: TextStyle(fontSize: 10, color: Colors.white),
),
),
],

View File

@@ -57,7 +57,7 @@ class MyRemoteDataSourceImpl implements MyRemoteDataSource {
id: 'about_us',
title: '关于我们',
icon: 'info',
subTitle: 'V2.1.0',
subTitle: 'V1.0.1',
),
];
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
@@ -10,6 +11,8 @@ import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart';
import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart';
import 'package:maibu_satabot_v2/core/update/update_cubit.dart';
import 'package:maibu_satabot_v2/core/update/update_state.dart';
/// 系统设置综合页面
class SystemSettingsPage extends StatefulWidget {
@@ -20,6 +23,25 @@ class SystemSettingsPage extends StatefulWidget {
}
class _SystemSettingsPageState extends State<SystemSettingsPage> {
String _currentVersion = '';
int _currentVersionCode = 0;
@override
void initState() {
super.initState();
_loadVersion();
}
Future<void> _loadVersion() async {
try {
final String versionString = await rootBundle.loadString('pubspec.yaml');
final versionMatch = RegExp(r'version:\s*(\d+\.\d+\.\d+)\+(\d+)').firstMatch(versionString);
setState(() {
_currentVersion = versionMatch?.group(1) ?? '1.0.0';
_currentVersionCode = int.tryParse(versionMatch?.group(2) ?? '1') ?? 1;
});
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -52,6 +74,9 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
// 语言设置
_buildLanguageSection(),
const SizedBox(height: 12),
// 版本更新
_buildUpdateSection(),
const SizedBox(height: 12),
// 退出登录
_buildLogoutButton(context),
const SizedBox(height: 20),
@@ -297,6 +322,105 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
);
}
/// 版本更新
Widget _buildUpdateSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.appColors.cardBackground,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: context.appColors.cardShadow,
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: BlocConsumer<UpdateCubit, UpdateState>(
listener: (context, state) {
if (state is UpdateUpToDate) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('当前已是最新版本'),
backgroundColor: context.appColors.success,
duration: const Duration(seconds: 2),
),
);
}
},
builder: (context, state) {
final isChecking = state is UpdateChecking;
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'版本更新',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: context.appColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
'当前版本: $_currentVersion ($_currentVersionCode)',
style: TextStyle(
fontSize: 13,
color: context.appColors.textTertiary,
),
),
const SizedBox(height: 2),
Text(
isChecking ? '正在检查更新...' : '点击检查是否有新版本',
style: TextStyle(
fontSize: 13,
color: context.appColors.textTertiary,
),
),
],
),
),
if (isChecking)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
GestureDetector(
onTap: () {
context.read<UpdateCubit>().checkUpdate();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFF165DFF),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'检查更新',
style: TextStyle(
fontSize: 14,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
],
);
},
),
);
}
/// 退出登录按钮
Widget _buildLogoutButton(BuildContext context) {
return GestureDetector(