diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index e4e698d9..1c9ab790 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -91,6 +91,7 @@ import '../../features/v2/device_list/domain/usecases/get_uav_video_stream_useca import '../../features/v2/device_list/domain/usecases/update_flight_task_status_usecase.dart'; import '../../features/v2/device_list/presentation/bloc/drone_station_bloc.dart'; import '../../features/v2/device_list/presentation/bloc/robot_list_bloc.dart'; +import '../../features/v2/device_list/presentation/bloc/device_realtime_bloc.dart'; import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart'; import '../../features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart'; import '../../features/v2/waring_center/data/repositories/alarm_repository_impl.dart'; @@ -126,6 +127,14 @@ import '../network/dio_client.dart'; import '../network/net_message_dispatcher.dart'; import '../network/tcp/tcp_client.dart'; import '../network/tcp/tcp_status_cubit.dart'; +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/domain/repositories/drone_osd_repository.dart'; +import '../network/mqtt/domain/repositories/task_message_repository.dart'; +import '../network/mqtt/data/repositories/drone_osd_repository_impl.dart'; +import '../network/mqtt/data/repositories/task_message_repository_impl.dart'; import '../router/app_router.dart'; import '../storage/impl/user_storage_impl.dart'; import '../storage/user_storage.dart'; @@ -148,7 +157,17 @@ Future init() async { ); sl.registerLazySingleton(() => PathPlanningService()); - /// 1.1.3 NetMessageDispatcher:消息调度器,并将 TcpClient 注入给它 + /// 1.1.3 MQTT Clients - 两个独立的 MQTT 客户端实例 + sl.registerLazySingleton( + () => MqttClientImpl(), + instanceName: 'droneOsdClient', + ); + sl.registerLazySingleton( + () => MqttClientImpl(), + instanceName: 'taskMessageClient', + ); + + /// 1.1.4 NetMessageDispatcher:消息调度器,并将 TcpClient 注入给它 sl.registerLazySingleton( () => NetMessageDispatcher( sl(), @@ -172,6 +191,22 @@ Future init() async { /// 1.3 Log工具Sentry sl.registerLazySingleton(() => SentryLoggerImpl()); + /// 1.4 --- MQTT Data Sources --- + sl.registerLazySingleton( + () => DroneOsdDataSourceImpl(sl(instanceName: 'droneOsdClient')), + ); + sl.registerLazySingleton( + () => TaskMessageDataSourceImpl(sl(instanceName: 'taskMessageClient')), + ); + + /// 1.5 --- MQTT Repositories --- + sl.registerLazySingleton( + () => DroneOsdRepositoryImpl(sl()), + ); + sl.registerLazySingleton( + () => TaskMessageRepositoryImpl(sl()), + ); + /// 2. 数据源 (DataSource) sl.registerLazySingleton( () => AuthHttpDataSourceImpl(sl()), @@ -326,6 +361,11 @@ Future init() async { /// Robot List V2 sl.registerFactory(() => RobotListBloc(sl())); + /// Device Realtime V2 (MQTT) + sl.registerFactory( + () => DeviceRealtimeBloc(sl()), + ); + /// Alarm Center V2 sl.registerLazySingleton( () => AlarmRemoteDataSourceImpl(), diff --git a/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart new file mode 100644 index 00000000..84289cfd --- /dev/null +++ b/lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; + +import '../../domain/interfaces/mqtt_client.dart'; +import '../../domain/models/mqtt_message.dart'; +import '../../domain/entities/drone_osd_entity.dart'; + +abstract class DroneOsdDataSource { + Stream get droneOsdStream; + Stream get stationOsdStream; + + Future startListening({ + required String deviceSn, + required String gatewaySn, + }); + + Future stopListening(); +} + +class DroneOsdDataSourceImpl implements DroneOsdDataSource { + final MqttClient mqttClient; + final _droneOsdController = StreamController.broadcast(); + final _stationOsdController = StreamController.broadcast(); + + StreamSubscription? _subscription; + String? _deviceSn; + String? _gatewaySn; + + DroneOsdDataSourceImpl(this.mqttClient); + + @override + Stream get droneOsdStream => _droneOsdController.stream; + + @override + Stream get stationOsdStream => _stationOsdController.stream; + + @override + Future startListening({ + required String deviceSn, + required String gatewaySn, + }) async { + _deviceSn = deviceSn; + _gatewaySn = gatewaySn; + + final droneTopic = 'thing/product/$deviceSn/osd'; + final stationTopic = 'thing/product/$gatewaySn/osd'; + + debugPrint('🛸 [DroneOsdDataSource] 开始监听:'); + debugPrint(' 无人机: $droneTopic'); + debugPrint(' 机场: $stationTopic'); + + await mqttClient.subscribe(droneTopic); + await mqttClient.subscribe(stationTopic); + + _subscription = mqttClient.messageStream?.listen((message) { + _handleMessage(message); + }); + } + + @override + Future stopListening() async { + await _subscription?.cancel(); + _subscription = null; + + if (_deviceSn != null) { + await mqttClient.unsubscribe('thing/product/$_deviceSn/osd'); + } + if (_gatewaySn != null) { + await mqttClient.unsubscribe('thing/product/$_gatewaySn/osd'); + } + + _deviceSn = null; + _gatewaySn = null; + } + + void _handleMessage(MqttMessage message) { + try { + final jsonData = jsonDecode(message.payload) as Map; + final osdData = DroneOsdEntity.fromJson(jsonData); + + if (message.topic.contains(_deviceSn ?? '')) { + debugPrint('🛸 [DroneOsdDataSource] 无人机 OSD 更新'); + _droneOsdController.add(osdData); + } else if (message.topic.contains(_gatewaySn ?? '')) { + debugPrint('🏢 [DroneOsdDataSource] 机场 OSD 更新'); + _stationOsdController.add(osdData); + } + } catch (e) { + debugPrint('❌ [DroneOsdDataSource] 解析 OSD 数据失败: $e'); + } + } + + void dispose() { + stopListening(); + _droneOsdController.close(); + _stationOsdController.close(); + } +} diff --git a/lib/core/network/mqtt/data/datasources/task_message_datasource.dart b/lib/core/network/mqtt/data/datasources/task_message_datasource.dart new file mode 100644 index 00000000..c975671b --- /dev/null +++ b/lib/core/network/mqtt/data/datasources/task_message_datasource.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; + +import '../../domain/interfaces/mqtt_client.dart'; +import '../../domain/models/mqtt_message.dart'; +import '../../domain/entities/task_arrive_entity.dart'; +import '../../domain/entities/task_status_entity.dart'; +import '../../domain/entities/real_time_message_entity.dart'; + +abstract class TaskMessageDataSource { + Stream get taskStatusStream; + Stream get taskArriveStream; + Stream get realTimeMessageStream; + + Future startListening({required String deviceId}); + Future stopListening(); +} + +class TaskMessageDataSourceImpl implements TaskMessageDataSource { + final MqttClient mqttClient; + final _taskStatusController = StreamController.broadcast(); + final _taskArriveController = StreamController.broadcast(); + final _realTimeMessageController = StreamController.broadcast(); + + StreamSubscription? _subscription; + String? _deviceId; + + TaskMessageDataSourceImpl(this.mqttClient); + + @override + Stream get taskStatusStream => _taskStatusController.stream; + + @override + Stream get taskArriveStream => _taskArriveController.stream; + + @override + Stream get realTimeMessageStream => + _realTimeMessageController.stream; + + @override + Future startListening({required String deviceId}) async { + _deviceId = deviceId; + + final taskStatusTopic = 'task/$deviceId/status'; + final taskArriveTopic = 'task/$deviceId/arrive'; + final realTimeTopic = 'device/$deviceId/realTimeMessage'; + + debugPrint('📋 [TaskMessageDataSource] 开始监听:'); + debugPrint(' 任务状态: $taskStatusTopic'); + debugPrint(' 到达通知: $taskArriveTopic'); + debugPrint(' 实时消息: $realTimeTopic'); + + await mqttClient.subscribe(taskStatusTopic); + await mqttClient.subscribe(taskArriveTopic); + await mqttClient.subscribe(realTimeTopic); + + _subscription = mqttClient.messageStream?.listen((message) { + _handleMessage(message); + }); + } + + @override + Future stopListening() async { + await _subscription?.cancel(); + _subscription = null; + + if (_deviceId != null) { + await mqttClient.unsubscribe('task/$_deviceId/status'); + await mqttClient.unsubscribe('task/$_deviceId/arrive'); + await mqttClient.unsubscribe('device/$_deviceId/realTimeMessage'); + } + + _deviceId = null; + } + + void _handleMessage(MqttMessage message) { + try { + final jsonData = jsonDecode(message.payload) as Map; + + if (message.topic.contains('/status')) { + final taskStatus = TaskStatusEntity.fromJson(jsonData); + debugPrint('📋 [TaskMessageDataSource] 任务状态更新: ${taskStatus.status}'); + _taskStatusController.add(taskStatus); + } else if (message.topic.contains('/arrive')) { + final arriveInfo = TaskArriveEntity.fromJson(jsonData); + debugPrint('📍 [TaskMessageDataSource] 到达通知: 任务${arriveInfo.taskId}'); + _taskArriveController.add(arriveInfo); + } else if (message.topic.contains('/realTimeMessage')) { + final realTimeMsg = RealTimeMessageEntity.fromJson(jsonData); + debugPrint('💬 [TaskMessageDataSource] 实时消息 - 类型: ${realTimeMsg.type}, 数据点数: ${realTimeMsg.data.length}'); + _realTimeMessageController.add(realTimeMsg); + } + } catch (e) { + debugPrint('❌ [TaskMessageDataSource] 解析任务消息失败: $e'); + } + } + + void dispose() { + stopListening(); + _taskStatusController.close(); + _taskArriveController.close(); + _realTimeMessageController.close(); + } +} diff --git a/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart b/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart new file mode 100644 index 00000000..b5c17f28 --- /dev/null +++ b/lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +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 '../../domain/interfaces/mqtt_client.dart'; +import '../../domain/models/mqtt_config.dart'; +import '../../domain/models/mqtt_message.dart'; + +class MqttClientImpl implements MqttClient { + mqtt_server.MqttServerClient? _client; + final _messageController = StreamController.broadcast(); + MqttConfig? _currentConfig; + bool _isConnected = false; + + @override + Stream? get messageStream => _messageController.stream; + + @override + bool get isConnected => _isConnected; + + @override + Future connect(MqttConfig config) async { + if (_isConnected) { + debugPrint('⚠️ [MqttClient] 已连接,先断开'); + await disconnect(); + } + + try { + _currentConfig = config; + final clientId = '${config.clientId}_${DateTime.now().millisecondsSinceEpoch}'; + + switch (config.protocol) { + case MqttProtocol.websocket: + _client = mqtt_server.MqttServerClient.withPort( + config.host, + clientId, + config.port, + ); + _client!.useWebSocket = true; + break; + case MqttProtocol.wss: + _client = mqtt_server.MqttServerClient.withPort( + config.host, + clientId, + config.port, + ); + _client!.useWebSocket = true; + _client!.secure = true; + break; + case MqttProtocol.tcp: + default: + _client = mqtt_server.MqttServerClient.withPort( + config.host, + clientId, + config.port, + ); + break; + } + + _client!.logging(on: false); + _client!.keepAlivePeriod = config.keepAlivePeriod; + _client!.autoReconnect = true; + _client!.resubscribeOnAutoReconnect = true; + _client!.onDisconnected = _onDisconnected; + _client!.onConnected = _onConnected; + _client!.onSubscribed = _onSubscribed; + + final connMessage = mqtt.MqttConnectMessage() + .withClientIdentifier(clientId) + .startClean() + .withWillQos(mqtt.MqttQos.atLeastOnce); + + _client!.connectionMessage = connMessage; + + debugPrint('🔌 [MqttClient] 开始连接到 ${config.host}:${config.port}'); + + await _client!.connect(config.username, config.password); + + if (_client!.connectionStatus?.state == mqtt.MqttConnectionState.connected) { + _isConnected = true; + debugPrint('✅ [MqttClient] 连接成功'); + _listenToMessages(); + } else { + throw Exception('连接失败: ${_client!.connectionStatus?.returnCode}'); + } + } catch (e) { + debugPrint('❌ [MqttClient] 连接异常: $e'); + _isConnected = false; + rethrow; + } + } + + @override + Future disconnect() async { + debugPrint('🔌 [MqttClient] 断开连接'); + _client?.disconnect(); + _isConnected = false; + _currentConfig = null; + } + + @override + Future subscribe(String topic) async { + if (!_isConnected || _client == null) { + throw Exception('MQTT 未连接'); + } + + debugPrint('📡 [MqttClient] 订阅主题: $topic'); + _client!.subscribe(topic, mqtt.MqttQos.atLeastOnce); + } + + @override + Future unsubscribe(String topic) async { + if (!_isConnected || _client == null) { + throw Exception('MQTT 未连接'); + } + + debugPrint('🔕 [MqttClient] 取消订阅: $topic'); + _client!.unsubscribe(topic); + } + + @override + Future publish(String topic, String message) async { + if (!_isConnected || _client == null) { + throw Exception('MQTT 未连接'); + } + + debugPrint('📤 [MqttClient] 发布消息到 $topic'); + final builder = mqtt.MqttClientPayloadBuilder(); + builder.addString(message); + _client!.publishMessage(topic, mqtt.MqttQos.atLeastOnce, builder.payload!); + } + + void _listenToMessages() { + _client!.updates!.listen((List> messages) { + for (final msg in messages) { + final topic = msg.topic; + final payload = mqtt.MqttPublishPayload.bytesToStringAsString((msg.payload as mqtt.MqttPublishMessage).payload.message); + + debugPrint('📥 [MqttClient] 收到消息 [$topic]: $payload'); + + _messageController.add(MqttMessage( + topic: topic, + payload: payload, + )); + } + }); + } + + void _onConnected() { + debugPrint('✅ [MqttClient] 已连接'); + _isConnected = true; + } + + void _onDisconnected() { + debugPrint('❌ [MqttClient] 已断开'); + _isConnected = false; + } + + void _onSubscribed(String topic) { + debugPrint('✅ [MqttClient] 订阅成功: $topic'); + } + + void dispose() { + _messageController.close(); + disconnect(); + } +} diff --git a/lib/core/network/mqtt/data/repositories/drone_osd_repository_impl.dart b/lib/core/network/mqtt/data/repositories/drone_osd_repository_impl.dart new file mode 100644 index 00000000..7ca8a8cb --- /dev/null +++ b/lib/core/network/mqtt/data/repositories/drone_osd_repository_impl.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'package:fpdart/fpdart.dart'; + +import '../../../../../core/error/failure.dart'; +import '../datasources/drone_osd_datasource.dart'; +import '../../domain/repositories/drone_osd_repository.dart'; +import '../../domain/entities/drone_osd_entity.dart'; + +class DroneOsdRepositoryImpl implements DroneOsdRepository { + final DroneOsdDataSource dataSource; + + DroneOsdRepositoryImpl(this.dataSource); + + @override + Stream get droneOsdStream => dataSource.droneOsdStream; + + @override + Stream get stationOsdStream => dataSource.stationOsdStream; + + @override + Future> startListening({ + required String deviceSn, + required String gatewaySn, + }) async { + try { + await dataSource.startListening( + deviceSn: deviceSn, + gatewaySn: gatewaySn, + ); + return right(null); + } catch (e) { + return left(Failure(e.toString())); + } + } + + @override + Future stopListening() async { + await dataSource.stopListening(); + } +} diff --git a/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart b/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart new file mode 100644 index 00000000..49723915 --- /dev/null +++ b/lib/core/network/mqtt/data/repositories/task_message_repository_impl.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'package:fpdart/fpdart.dart'; + +import '../../../../../core/error/failure.dart'; +import '../datasources/task_message_datasource.dart'; +import '../../domain/repositories/task_message_repository.dart'; +import '../../domain/entities/task_arrive_entity.dart'; +import '../../domain/entities/task_status_entity.dart'; +import '../../domain/entities/real_time_message_entity.dart'; + +class TaskMessageRepositoryImpl implements TaskMessageRepository { + final TaskMessageDataSource dataSource; + + TaskMessageRepositoryImpl(this.dataSource); + + @override + Stream get realTimeMessageStream => + dataSource.realTimeMessageStream; + + @override + Stream get taskArriveStream => dataSource.taskArriveStream; + + @override + Stream get taskStatusStream => dataSource.taskStatusStream; + + @override + Future> startListening({required String deviceId}) async { + try { + await dataSource.startListening(deviceId: deviceId); + return right(null); + } catch (e) { + return left(Failure(e.toString())); + } + } + + @override + Future stopListening() async { + await dataSource.stopListening(); + } +} diff --git a/lib/core/network/mqtt/domain/entities/drone_osd_entity.dart b/lib/core/network/mqtt/domain/entities/drone_osd_entity.dart new file mode 100644 index 00000000..6d37acbe --- /dev/null +++ b/lib/core/network/mqtt/domain/entities/drone_osd_entity.dart @@ -0,0 +1,18 @@ +import 'package:equatable/equatable.dart'; + +class DroneOsdEntity extends Equatable { + final Map rawData; + + const DroneOsdEntity({required this.rawData}); + + factory DroneOsdEntity.fromJson(Map json) { + return DroneOsdEntity(rawData: json); + } + + Map toJson() { + return rawData; + } + + @override + List get props => [rawData]; +} diff --git a/lib/core/network/mqtt/domain/entities/real_time_message_entity.dart b/lib/core/network/mqtt/domain/entities/real_time_message_entity.dart new file mode 100644 index 00000000..62583fd2 --- /dev/null +++ b/lib/core/network/mqtt/domain/entities/real_time_message_entity.dart @@ -0,0 +1,69 @@ +import 'package:equatable/equatable.dart'; + +class RealTimeMessageEntity extends Equatable { + final List data; + final String type; + + const RealTimeMessageEntity({ + required this.data, + required this.type, + }); + + factory RealTimeMessageEntity.fromJson(Map json) { + final dataList = json['data'] as List? ?? []; + return RealTimeMessageEntity( + data: dataList.map((item) => DeviceDataPoint.fromJson(item)).toList(), + type: json['type'] as String? ?? '', + ); + } + + double? getValueByName(String name) { + final point = data.firstWhere( + (p) => p.name == name, + orElse: () => DeviceDataPoint(name: '', value: '', unit: ''), + ); + if (point.value.isEmpty) return null; + return double.tryParse(point.value); + } + + Map toJson() { + return { + 'data': data.map((e) => e.toJson()).toList(), + 'type': type, + }; + } + + @override + List get props => [data, type]; +} + +class DeviceDataPoint extends Equatable { + final String name; + final String value; + final String unit; + + const DeviceDataPoint({ + required this.name, + required this.value, + required this.unit, + }); + + factory DeviceDataPoint.fromJson(Map json) { + return DeviceDataPoint( + name: json['name'] as String? ?? '', + value: json['value'] as String? ?? '', + unit: json['unit'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'name': name, + 'value': value, + 'unit': unit, + }; + } + + @override + List get props => [name, value, unit]; +} diff --git a/lib/core/network/mqtt/domain/entities/task_arrive_entity.dart b/lib/core/network/mqtt/domain/entities/task_arrive_entity.dart new file mode 100644 index 00000000..82392633 --- /dev/null +++ b/lib/core/network/mqtt/domain/entities/task_arrive_entity.dart @@ -0,0 +1,67 @@ +import 'package:equatable/equatable.dart'; + +class TaskArriveEntity extends Equatable { + final String type; + final String deviceId; + final int taskId; + final dynamic status; + final ArriveLocation entity; + + const TaskArriveEntity({ + required this.type, + required this.deviceId, + required this.taskId, + this.status, + required this.entity, + }); + + factory TaskArriveEntity.fromJson(Map json) { + return TaskArriveEntity( + type: json['type'] as String? ?? '', + deviceId: json['deviceId'] as String? ?? '', + taskId: json['taskId'] as int? ?? 0, + status: json['status'], + entity: ArriveLocation.fromJson(json['entity'] as Map? ?? {}), + ); + } + + Map toJson() { + return { + 'type': type, + 'deviceId': deviceId, + 'taskId': taskId, + 'status': status, + 'entity': entity.toJson(), + }; + } + + @override + List get props => [type, deviceId, taskId, status, entity]; +} + +class ArriveLocation extends Equatable { + final double lat; + final double lng; + + const ArriveLocation({ + required this.lat, + required this.lng, + }); + + factory ArriveLocation.fromJson(Map json) { + return ArriveLocation( + lat: (json['lat'] as num?)?.toDouble() ?? 0.0, + lng: (json['lng'] as num?)?.toDouble() ?? 0.0, + ); + } + + Map toJson() { + return { + 'lat': lat, + 'lng': lng, + }; + } + + @override + List get props => [lat, lng]; +} diff --git a/lib/core/network/mqtt/domain/entities/task_status_entity.dart b/lib/core/network/mqtt/domain/entities/task_status_entity.dart new file mode 100644 index 00000000..3fb97b7b --- /dev/null +++ b/lib/core/network/mqtt/domain/entities/task_status_entity.dart @@ -0,0 +1,40 @@ +import 'package:equatable/equatable.dart'; + +class TaskStatusEntity extends Equatable { + final String type; + final String deviceId; + final int taskId; + final String? status; + final Map? extraData; + + const TaskStatusEntity({ + required this.type, + required this.deviceId, + required this.taskId, + this.status, + this.extraData, + }); + + factory TaskStatusEntity.fromJson(Map json) { + return TaskStatusEntity( + type: json['type'] as String? ?? '', + deviceId: json['deviceId'] as String? ?? '', + taskId: json['taskId'] as int? ?? 0, + status: json['status'] as String?, + extraData: json, + ); + } + + Map toJson() { + return { + 'type': type, + 'deviceId': deviceId, + 'taskId': taskId, + 'status': status, + if (extraData != null) ...extraData!, + }; + } + + @override + List get props => [type, deviceId, taskId, status, extraData]; +} diff --git a/lib/core/network/mqtt/domain/interfaces/mqtt_client.dart b/lib/core/network/mqtt/domain/interfaces/mqtt_client.dart new file mode 100644 index 00000000..9a491029 --- /dev/null +++ b/lib/core/network/mqtt/domain/interfaces/mqtt_client.dart @@ -0,0 +1,14 @@ +import 'dart:async'; +import '../models/mqtt_config.dart'; +import '../models/mqtt_message.dart'; + +abstract class MqttClient { + Stream? get messageStream; + + Future connect(MqttConfig config); + Future disconnect(); + Future subscribe(String topic); + Future unsubscribe(String topic); + Future publish(String topic, String message); + bool get isConnected; +} diff --git a/lib/core/network/mqtt/domain/models/mqtt_config.dart b/lib/core/network/mqtt/domain/models/mqtt_config.dart new file mode 100644 index 00000000..d6209209 --- /dev/null +++ b/lib/core/network/mqtt/domain/models/mqtt_config.dart @@ -0,0 +1,90 @@ + import 'package:equatable/equatable.dart'; + +enum MqttProtocol { tcp, websocket, wss } + +class MqttConfig extends Equatable { + final String host; + final int port; + final String username; + final String password; + final MqttProtocol protocol; + final String clientId; + final bool cleanSession; + final int keepAlivePeriod; + final int reconnectDelayMs; + + const MqttConfig({ + required this.host, + required this.port, + required this.username, + required this.password, + required this.protocol, + required this.clientId, + this.cleanSession = true, + this.keepAlivePeriod = 60, + this.reconnectDelayMs = 3000, + }); + + factory MqttConfig.droneOsd() { + return const MqttConfig( + host: '1.95.137.212', + port: 8083, + username: 'maibu', + password: 'jsmbzn520', + protocol: MqttProtocol.websocket, + clientId: 'drone_osd_client', + cleanSession: true, + reconnectDelayMs: 3000, + ); + } + + factory MqttConfig.taskMessage() { + return const MqttConfig( + host: '1.95.137.212', + port: 59020, + username: 'maibu', + password: 'jsmbzn520', + protocol: MqttProtocol.tcp, + clientId: 'task_message_client', + cleanSession: true, + reconnectDelayMs: 3000, + ); + } + + MqttConfig copyWith({ + String? host, + int? port, + String? username, + String? password, + MqttProtocol? protocol, + String? clientId, + bool? cleanSession, + int? keepAlivePeriod, + int? reconnectDelayMs, + }) { + return MqttConfig( + host: host ?? this.host, + port: port ?? this.port, + username: username ?? this.username, + password: password ?? this.password, + protocol: protocol ?? this.protocol, + clientId: clientId ?? this.clientId, + cleanSession: cleanSession ?? this.cleanSession, + keepAlivePeriod: keepAlivePeriod ?? this.keepAlivePeriod, + reconnectDelayMs: reconnectDelayMs ?? this.reconnectDelayMs, + ); + } + + @override + List get props => [ + host, + port, + username, + password, + protocol, + clientId, + cleanSession, + keepAlivePeriod, + reconnectDelayMs, + ]; +} diff --git a/lib/core/network/mqtt/domain/models/mqtt_message.dart b/lib/core/network/mqtt/domain/models/mqtt_message.dart new file mode 100644 index 00000000..f326f633 --- /dev/null +++ b/lib/core/network/mqtt/domain/models/mqtt_message.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; + +class MqttMessage extends Equatable { + final String topic; + final String payload; + final DateTime timestamp; + + MqttMessage({ + required this.topic, + required this.payload, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); + + Map toJson() { + return { + 'topic': topic, + 'payload': payload, + 'timestamp': timestamp.toIso8601String(), + }; + } + + @override + List get props => [topic, payload, timestamp]; +} diff --git a/lib/core/network/mqtt/domain/repositories/drone_osd_repository.dart b/lib/core/network/mqtt/domain/repositories/drone_osd_repository.dart new file mode 100644 index 00000000..19050d98 --- /dev/null +++ b/lib/core/network/mqtt/domain/repositories/drone_osd_repository.dart @@ -0,0 +1,17 @@ +import 'dart:async'; +import 'package:fpdart/fpdart.dart'; + +import '../../../../../core/error/failure.dart'; +import '../entities/drone_osd_entity.dart'; + +abstract class DroneOsdRepository { + Stream get droneOsdStream; + Stream get stationOsdStream; + + Future> startListening({ + required String deviceSn, + required String gatewaySn, + }); + + Future stopListening(); +} diff --git a/lib/core/network/mqtt/domain/repositories/task_message_repository.dart b/lib/core/network/mqtt/domain/repositories/task_message_repository.dart new file mode 100644 index 00000000..ab99c41b --- /dev/null +++ b/lib/core/network/mqtt/domain/repositories/task_message_repository.dart @@ -0,0 +1,16 @@ +import 'dart:async'; +import 'package:fpdart/fpdart.dart'; + +import '../../../../../core/error/failure.dart'; +import '../entities/task_arrive_entity.dart'; +import '../entities/task_status_entity.dart'; +import '../entities/real_time_message_entity.dart'; + +abstract class TaskMessageRepository { + Stream get realTimeMessageStream; + Stream get taskArriveStream; + Stream get taskStatusStream; + + Future> startListening({required String deviceId}); + Future stopListening(); +} diff --git a/lib/core/network/mqtt/mqtt_manager.dart b/lib/core/network/mqtt/mqtt_manager.dart new file mode 100644 index 00000000..f00fa4dd --- /dev/null +++ b/lib/core/network/mqtt/mqtt_manager.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:get_it/get_it.dart'; + +import '../../di/injection.dart'; +import 'data/datasources/drone_osd_datasource.dart'; +import 'data/datasources/task_message_datasource.dart'; +import 'domain/interfaces/mqtt_client.dart'; +import 'domain/models/mqtt_config.dart'; + +class MqttManager { + static final MqttManager _instance = MqttManager._internal(); + factory MqttManager() => _instance; + MqttManager._internal(); + + bool _isInitialized = false; + + Future initialize() async { + if (_isInitialized) { + debugPrint('⚠️ [MqttManager] 已初始化,跳过'); + return; + } + + try { + debugPrint('🔧 [MqttManager] 开始初始化 MQTT 连接...'); + + final droneOsdClient = sl(instanceName: 'droneOsdClient'); + final taskMessageClient = sl(instanceName: 'taskMessageClient'); + + await droneOsdClient.connect(MqttConfig.droneOsd()); + await taskMessageClient.connect(MqttConfig.taskMessage()); + + _isInitialized = true; + debugPrint('✅ [MqttManager] MQTT 初始化完成'); + } catch (e) { + debugPrint('❌ [MqttManager] MQTT 初始化失败: $e'); + rethrow; + } + } + + Future dispose() async { + debugPrint('🔌 [MqttManager] 断开所有 MQTT 连接...'); + + final droneOsdClient = sl(instanceName: 'droneOsdClient'); + final taskMessageClient = sl(instanceName: 'taskMessageClient'); + + await droneOsdClient.disconnect(); + await taskMessageClient.disconnect(); + + _isInitialized = false; + debugPrint('✅ [MqttManager] 所有 MQTT 连接已断开'); + } + + bool get isInitialized => _isInitialized; +} diff --git a/lib/features/devices/presentation/bloc/device_task_cubit.dart b/lib/features/devices/presentation/bloc/device_task_cubit.dart index 8d9a39dc..1588e69d 100644 --- a/lib/features/devices/presentation/bloc/device_task_cubit.dart +++ b/lib/features/devices/presentation/bloc/device_task_cubit.dart @@ -4,6 +4,7 @@ import '../../../../core/app/app_user_cubit.dart'; import '../../../../core/logging/i_logger_service.dart'; import '../../../../core/network/error_handler.dart'; import '../../../../features/v2/site/presentation/cubit/site_cubit.dart'; +import '../../domain/entities/device_task_entity.dart'; // 🔥 新增 import '../../domain/usecases/cancel_task_usecase.dart'; import '../../domain/usecases/get_device_task_pool_usecase.dart'; import '../../domain/usecases/pause_task_usecase.dart'; @@ -70,23 +71,32 @@ class DeviceTaskCubit extends Cubit { )); }, (taskList) { - // 过滤出当前设备的任务 - final deviceTasks = taskList - .where((task) => task.deviceId == deviceId) - .toList(); - - // 取第一个任务(或根据业务逻辑选择) - final currentTask = deviceTasks.isNotEmpty ? deviceTasks.first : null; + // 🔥 过滤出当前设备 + 活跃状态的任务(NEW, EXECUTING, PAUSE) + final activeTasks = taskList.where((task) { + // 1. 设备号匹配 + if (task.deviceId != deviceId) return false; + + // 2. 状态过滤:只保留新建、执行中、暂停中的任务 + final status = task.taskStatus; + return status == 'NEW' || // 新建 + status == 'EXECUTING' || // 执行中 + status == 'PAUSE'; // 暂停中 + }).toList(); _logger.logWithLevel( - '✅ 找到 ${deviceTasks.length} 个任务,当前任务ID: ${currentTask?.id}', + '✅ 找到 ${activeTasks.length} 个活跃任务', ); + // 🔥 如果有多个任务,保存所有选项供用户选择 + // 如果只有1个,直接选中 + final currentTask = activeTasks.isNotEmpty ? activeTasks.first : null; + emit(state.copyWith( isLoading: false, taskPool: taskList, currentTask: currentTask, currentTaskId: currentTask?.id, + activeTasks: activeTasks, // 🔥 保存所有活跃任务列表 )); }, ); @@ -304,6 +314,15 @@ class DeviceTaskCubit extends Cubit { _logger.logWithLevel('🔄 更新当前任务ID: $taskId'); } + /// 🔥 手动选择任务(用户从弹窗中选择) + void selectTask(DeviceTaskEntity task) { + _logger.logWithLevel('✅ 用户选择任务ID: ${task.id}, 状态: ${task.taskStatus}'); + emit(state.copyWith( + currentTask: task, + currentTaskId: task.id, + )); + } + /// 清除当前任务 void clearCurrentTask() { emit(state.copyWith( diff --git a/lib/features/devices/presentation/bloc/device_task_state.dart b/lib/features/devices/presentation/bloc/device_task_state.dart index 6ac01a9b..911239d6 100644 --- a/lib/features/devices/presentation/bloc/device_task_state.dart +++ b/lib/features/devices/presentation/bloc/device_task_state.dart @@ -12,6 +12,7 @@ class DeviceTaskState extends Equatable { final List taskPool; final DeviceTaskEntity? currentTask; final int? currentTaskId; + final List activeTasks; // 🔥 活跃任务列表(供用户选择) final bool isLoading; final String? errorMessage; final DeviceTaskOperationType operationType; @@ -21,6 +22,7 @@ class DeviceTaskState extends Equatable { this.taskPool = const [], this.currentTask, this.currentTaskId, + this.activeTasks = const [], // 🔥 默认空列表 this.isLoading = false, this.errorMessage, this.operationType = DeviceTaskOperationType.none, @@ -31,6 +33,7 @@ class DeviceTaskState extends Equatable { List? taskPool, DeviceTaskEntity? currentTask, int? currentTaskId, + List? activeTasks, // 🔥 新增 bool? isLoading, String? errorMessage, DeviceTaskOperationType? operationType, @@ -40,6 +43,7 @@ class DeviceTaskState extends Equatable { taskPool: taskPool ?? this.taskPool, currentTask: currentTask ?? this.currentTask, currentTaskId: currentTaskId ?? this.currentTaskId, + activeTasks: activeTasks ?? this.activeTasks, // 🔥 新增 isLoading: isLoading ?? this.isLoading, errorMessage: errorMessage, operationType: operationType ?? this.operationType, @@ -52,6 +56,7 @@ class DeviceTaskState extends Equatable { taskPool, currentTask, currentTaskId, + activeTasks, // 🔥 新增 isLoading, errorMessage, operationType, diff --git a/lib/features/home/presentation/widgets/ImmersionHeader.dart b/lib/features/home/presentation/widgets/ImmersionHeader.dart index 47caedbc..ce707a91 100644 --- a/lib/features/home/presentation/widgets/ImmersionHeader.dart +++ b/lib/features/home/presentation/widgets/ImmersionHeader.dart @@ -13,6 +13,7 @@ import '../../../../core/app/app_user_cubit.dart'; import '../../../../core/router/route_paths.dart'; import '../../../devices/presentation/bloc/devices_cubit.dart'; import '../../../devices/presentation/bloc/devices_state.dart'; +import '../../../remote_control/presentation/bloc/remote_control_cubit.dart'; // 🔥 添加 RemoteControlCubit // 导入你的主题文件以获取 offWhite // import 'package:maibu_satabot_v2/core/theme/app_theme.dart'; @@ -126,11 +127,18 @@ class ImmersionHeader extends StatelessWidget { children: [ _buildCircleIcon(Icons.sync, () { // 1. 触发 Cubit 请求最新设备列表 - // 假设你的 username 存储在 AuthCubit 或类似的全局状态中 final username = context.read().state.user?.username ?? ""; - context.read().fetchAllDevices(username); + + // 🔥 修复:检查是否已有 targetDevice + final remoteControlState = context.read().state; + if (remoteControlState.targetDevice == null) { + debugPrint('🔄 [ImmersionHeader] 无 targetDevice,开始加载设备列表'); + context.read().fetchAllDevices(username); + } else { + debugPrint('✅ [ImmersionHeader] 已有 targetDevice: ${remoteControlState.targetDevice!.deviceName},跳过加载'); + } // 2. 弹出窗口(窗口内部会根据状态显示转圈或列表) _showDeviceSwitcher(context); diff --git a/lib/features/home/presentation/widgets/map/testmap_pages.dart b/lib/features/home/presentation/widgets/map/testmap_pages.dart index b41bbe7c..4a5b60e6 100644 --- a/lib/features/home/presentation/widgets/map/testmap_pages.dart +++ b/lib/features/home/presentation/widgets/map/testmap_pages.dart @@ -36,6 +36,9 @@ import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_statu import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_state.dart'; +import 'package:maibu_satabot_v2/features/devices/domain/entities/device_task_entity.dart'; // 🔥 新增 +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_task_cubit.dart'; // 🔥 新增 +import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_task_state.dart'; // 🔥 新增 import 'package:maibu_satabot_v2/features/v2/site/presentation/cubit/site_cubit.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/BottomDirectionLine.dart'; import 'package:maibu_satabot_v2/features/home/presentation/widgets/common/commonFn.dart'; @@ -1978,6 +1981,102 @@ class _MapPageEnterpriseState extends State { ); } + /// 🔥 显示任务选择弹窗 + Future _showTaskSelectionDialog( + List tasks, + ) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: const Text('选择任务'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + // 状态标签颜色 + Color statusColor; + String statusText; + switch (task.taskStatus) { + case 'NEW': + statusColor = Colors.blue; + statusText = '新建'; + break; + case 'EXECUTING': + statusColor = Colors.green; + statusText = '执行中'; + break; + case 'PAUSE': + statusColor = Colors.orange; + statusText = '暂停中'; + break; + default: + statusColor = Colors.grey; + statusText = task.taskStatusTranslate; + } + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + title: Text('任务 #${task.id}'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('设备号: ${task.deviceId}'), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + statusText, + style: TextStyle( + color: statusColor, + fontSize: 12, + ), + ), + ), + if (task.createTime != null) ...[ + const SizedBox(width: 8), + Text( + task.createTime!, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ], + ], + ), + ], + ), + onTap: () { + Navigator.pop(dialogContext, task); + }, + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, null), + child: const Text('取消'), + ), + ], + ), + ); + } + /// 开始作业 void _startWork() async { // 🔥 V2 适配:使用接口方式执行作业,不再使用 TCP @@ -2010,13 +2109,53 @@ class _MapPageEnterpriseState extends State { return; } - // 5. 打印请求参数日志 + // 🔥 5. 先过滤出活跃任务,让用户选择 + debugPrint('🔍 [开始作业] 正在查询活跃任务...'); + final taskCubit = sl(); + await taskCubit.fetchAndFilterTask(deviceId); + + // 等待一下让状态更新 + await Future.delayed(const Duration(milliseconds: 100)); + + final activeTasks = taskCubit.state.activeTasks; + debugPrint('✅ [开始作业] 找到 ${activeTasks.length} 个活跃任务'); + + if (activeTasks.isEmpty) { + _showPageToast( + message: "当前设备没有活跃任务(新建/执行中/暂停中)", + type: ToastType.warn, + ); + return; + } + + // 🔥 6. 如果有多条任务,显示选择弹窗 + DeviceTaskEntity? selectedTask; + if (activeTasks.length > 1) { + debugPrint('⚠️ [开始作业] 有多个活跃任务,显示选择弹窗'); + selectedTask = await _showTaskSelectionDialog(activeTasks); + if (selectedTask == null) { + // 用户取消选择 + debugPrint('❌ [开始作业] 用户取消选择任务'); + return; + } + // 用户选择了任务,更新 cubit + taskCubit.selectTask(selectedTask); + } else { + // 只有1条任务,直接使用 + selectedTask = activeTasks.first; + debugPrint('✅ [开始作业] 自动选择唯一任务 #${selectedTask.id}'); + } + + debugPrint('📋 [开始作业] 最终选择的任务ID: ${selectedTask.id}, 状态: ${selectedTask.taskStatus}'); + + // 7. 打印请求参数日志 debugPrint('🚀 [开始作业] 请求参数:'); debugPrint(' ├─ deviceId: $deviceId'); debugPrint(' ├─ routeId: $routeId'); + debugPrint(' ├─ taskId: ${selectedTask.id}'); debugPrint(' └─ siteId: $siteId'); - // 6. 更新UI状态 + // 8. 更新UI状态 setState(() { isStopWork = false; isStartWork = true; @@ -2026,11 +2165,11 @@ class _MapPageEnterpriseState extends State { gctracePoint?.clear(); }); - // 7. 更新应用状态 + // 9. 更新应用状态 context.read().updateAppState(AppState.routePlanning); try { - // 8. 调用接口创建设备任务 + // 10. 调用接口创建设备任务 final result = await sl().execute( deviceId: deviceId, routeId: routeId, @@ -2107,18 +2246,63 @@ class _MapPageEnterpriseState extends State { /// 暂停作业 void _pauseWork() async { + // 🔥 获取设备ID和taskId + final targetDevice = context.read().state.targetDevice; + final deviceId = targetDevice?.deviceName; + if (deviceId == null || deviceId.isEmpty) { + _showPageToast(message: "请先选择一个设备", type: ToastType.info); + return; + } + + final taskCubit = sl(); + final taskId = taskCubit.state.currentTaskId; + if (taskId == null) { + _showPageToast(message: "无可用任务", type: ToastType.warn); + return; + } + + debugPrint('⏸️ [暂停作业] deviceId: $deviceId, taskId: $taskId'); + setState(() { isStartWork = false; // 🔥 关键:停止作业标志 _workStatus = WorkStatus.paused; }); _saveDataToLocal(); // 🔥 保存暂停状态 + + // 🔥 调用接口暂停任务(注释掉原有的 TCP 方式) + try { + await taskCubit.pauseTask(deviceId); + _showPageToast(message: "作业已暂停", type: ToastType.success); + } catch (e) { + debugPrint('❌ [暂停作业] 异常: $e'); + _showPageToast(message: "暂停失败: $e", type: ToastType.error); + } + + // 原有的 TCP 方式(已注释) //context.read().updateAppState(AppState.none); - await context.read().pauseRoutePlanning(); + //await context.read().pauseRoutePlanning(); } void _stopWork() async { _logger.log('按下停止按钮'); + // 🔥 获取设备ID和taskId + final targetDevice = context.read().state.targetDevice; + final deviceId = targetDevice?.deviceName; + if (deviceId == null || deviceId.isEmpty) { + _showPageToast(message: "请先选择一个设备", type: ToastType.info); + return; + } + + final taskCubit = sl(); + final taskId = taskCubit.state.currentTaskId; + if (taskId == null) { + _showPageToast(message: "无可用任务", type: ToastType.warn); + return; + } + + debugPrint('⏹️ [停止作业] deviceId: $deviceId, taskId: $taskId'); + if (mounted) { setState(() { isStartWork = false; @@ -2133,7 +2317,18 @@ class _MapPageEnterpriseState extends State { }); } - await context.read().stopRoutePlanning(); + // 🔥 调用接口取消任务(注释掉原有的 TCP 方式) + try { + await taskCubit.cancelTask(deviceId); + _showPageToast(message: "作业已停止", type: ToastType.success); + } catch (e) { + debugPrint('❌ [停止作业] 异常: $e'); + _showPageToast(message: "停止失败: $e", type: ToastType.error); + } + + // 原有的 TCP 方式(已注释) + //await context.read().stopRoutePlanning(); + _saveDataToLocal(); await Future.delayed(const Duration(milliseconds: 300)); if (mounted) { @@ -2152,12 +2347,40 @@ class _MapPageEnterpriseState extends State { /// 继续作业 void _resumeWork() async { + // 🔥 获取设备ID和taskId + final targetDevice = context.read().state.targetDevice; + final deviceId = targetDevice?.deviceName; + if (deviceId == null || deviceId.isEmpty) { + _showPageToast(message: "请先选择一个设备", type: ToastType.info); + return; + } + + final taskCubit = sl(); + final taskId = taskCubit.state.currentTaskId; + if (taskId == null) { + _showPageToast(message: "无可用任务", type: ToastType.warn); + return; + } + + debugPrint('▶️ [继续作业] deviceId: $deviceId, taskId: $taskId'); + setState(() { _workStatus = WorkStatus.working; }); _saveDataToLocal(); // 🔥 保存暂停状态 - context.read().updateAppState(AppState.routePlanning); - await context.read().resumeRoutePlanning(); + + // 🔥 调用接口恢复任务(注释掉原有的 TCP 方式) + try { + await taskCubit.recoveryTask(deviceId); + _showPageToast(message: "作业已恢复", type: ToastType.success); + } catch (e) { + debugPrint('❌ [继续作业] 异常: $e'); + _showPageToast(message: "恢复失败: $e", type: ToastType.error); + } + + // 原有的 TCP 方式(已注释) + //context.read().updateAppState(AppState.routePlanning); + //await context.read().resumeRoutePlanning(); } void _showDeleteConfirmDialog(PlotData plot, Function(PlotData) onDelete) { @@ -2284,7 +2507,147 @@ class _MapPageEnterpriseState extends State { const SizedBox(height: 16), - // 2. 错误提示(如果加载失败) + // 🔥 3. 当前任务信息显示(可点击重新选择) + BlocBuilder( + builder: (context, taskState) { + final currentTask = taskState.currentTask; + if (currentTask == null) { + return const SizedBox.shrink(); + } + + // 状态标签颜色 + Color statusColor; + String statusText; + switch (currentTask.taskStatus) { + case 'NEW': + statusColor = Colors.blue; + statusText = '新建'; + break; + case 'EXECUTING': + statusColor = Colors.green; + statusText = '执行中'; + break; + case 'PAUSE': + statusColor = Colors.orange; + statusText = '暂停中'; + break; + default: + statusColor = Colors.grey; + statusText = currentTask.taskStatusTranslate; + } + + return GestureDetector( + onTap: () async { + // 🔥 点击后显示所有活跃任务供用户选择 + final activeTasks = taskState.activeTasks; + if (activeTasks.isEmpty) { + _showPageToast( + message: "没有可用的任务", + type: ToastType.warn, + ); + return; + } + + final selectedTask = await _showTaskSelectionDialog( + activeTasks, + ); + if (selectedTask != null) { + // 用户选择了新任务,更新 cubit + sl().selectTask(selectedTask); + _showPageToast( + message: "已切换到任务 #${selectedTask.id}", + type: ToastType.success, + ); + } + }, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF5F7FA), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFF165DFF).withOpacity(0.2), + ), + ), + child: Row( + children: [ + Icon( + Icons.assignment_outlined, + color: const Color(0xFF165DFF), + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '当前任务: #${currentTask.id}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + '设备: ${currentTask.deviceId}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF4E5969), + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular( + 4, + ), + ), + child: Text( + statusText, + style: TextStyle( + color: statusColor, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + ), + Icon( + Icons.swap_horiz, + color: const Color(0xFF165DFF), + size: 18, + ), + const SizedBox(width: 4), + const Text( + '切换', + style: TextStyle( + fontSize: 12, + color: Color(0xFF165DFF), + ), + ), + ], + ), + ), + ); + }, + ), + + const SizedBox(height: 16), + + // 4. 错误提示(如果加载失败) if (startWorkList.isEmpty || headingStatus == 0) Row( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/machine_details/presentation/pages/machine_details_page.dart b/lib/features/machine_details/presentation/pages/machine_details_page.dart index bce2fd07..d6ea72c9 100644 --- a/lib/features/machine_details/presentation/pages/machine_details_page.dart +++ b/lib/features/machine_details/presentation/pages/machine_details_page.dart @@ -10,6 +10,7 @@ import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubi import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_state.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_state.dart'; +import 'package:maibu_satabot_v2/features/remote_control/presentation/bloc/remote_control_cubit.dart'; // 🔥 添加 RemoteControlCubit class MachineDetailsPage extends StatefulWidget { final DeviceEntity? device; @@ -157,7 +158,15 @@ class _MachineDetailsPageState extends State { Navigator.pop(context); if (context.mounted) { final username = context.read().state.user?.username ?? ""; - context.read().fetchAllDevices(username); + + // 🔥 修复:检查是否已有 targetDevice + final remoteControlState = context.read().state; + if (remoteControlState.targetDevice == null) { + debugPrint('🔄 [MachineDetails] 无 targetDevice,开始加载设备列表'); + context.read().fetchAllDevices(username); + } else { + debugPrint('✅ [MachineDetails] 已有 targetDevice: ${remoteControlState.targetDevice!.deviceName},跳过加载'); + } } }, ), @@ -187,7 +196,14 @@ class _MachineDetailsPageState extends State { Navigator.pop(context); if (context.mounted) { final username = context.read().state.user?.username ?? ""; - context.read().fetchAllDevices(username); + // 🔥 修复:检查是否已有 targetDevice + final remoteControlState = context.read().state; + if (remoteControlState.targetDevice == null) { + debugPrint('🔄 [MachineDetails AppBar] 无 targetDevice,开始加载设备列表'); + context.read().fetchAllDevices(username); + } else { + debugPrint('✅ [MachineDetails AppBar] 已有 targetDevice: ${remoteControlState.targetDevice!.deviceName},跳过加载'); + } } }, ), @@ -612,7 +628,15 @@ class _MachineDetailsPageState extends State { ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).translate('machine_details.unbind_success').replaceAll('%s', _deviceName)), backgroundColor: Colors.green, duration: const Duration(seconds: 2))); final username = context.read().state.user?.username ?? ""; - context.read().fetchAllDevices(username); + + // 🔥 修复:检查是否已有 targetDevice + final remoteControlState = context.read().state; + if (remoteControlState.targetDevice == null) { + debugPrint('🔄 [MachineDetails Unbind] 无 targetDevice,开始加载设备列表'); + context.read().fetchAllDevices(username); + } else { + debugPrint('✅ [MachineDetails Unbind] 已有 targetDevice: ${remoteControlState.targetDevice!.deviceName},跳过加载'); + } } } else if (state.errorMessage?.isNotEmpty == true && !state.isLoading) { if (context.mounted) { @@ -636,9 +660,9 @@ class _MachineDetailsPageState extends State { } // 处理结果:放宽条件,只要无错误信息就认为成功(不管loading) - print( - "处理修改名称结果,operationType: ${state.operationType}, isLoading: ${state.isLoading}, errorMessage: ${state.errorMessage},state.errorMessage?.isEmpty: ${state.errorMessage?.isEmpty}", - ); + // print( + // "处理修改名称结果,operationType: ${state.operationType}, isLoading: ${state.isLoading}, errorMessage: ${state.errorMessage},state.errorMessage?.isEmpty: ${state.errorMessage?.isEmpty}", + // ); // 核心修改:去掉!state.isLoading的判断 if (state.errorMessage?.isEmpty == true) { diff --git a/lib/features/v2/device_list/presentation/bloc/device_realtime_bloc.dart b/lib/features/v2/device_list/presentation/bloc/device_realtime_bloc.dart new file mode 100644 index 00000000..ba8247da --- /dev/null +++ b/lib/features/v2/device_list/presentation/bloc/device_realtime_bloc.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter/foundation.dart'; + +import '../../../../../../core/network/mqtt/domain/repositories/task_message_repository.dart'; +import 'device_realtime_event.dart'; +import 'device_realtime_state.dart'; + +class DeviceRealtimeBloc extends Bloc { + final TaskMessageRepository _taskMessageRepository; + StreamSubscription? _realtimeSubscription; + String? _currentDeviceId; + + DeviceRealtimeBloc(this._taskMessageRepository) : super(const DeviceRealtimeInitial()) { + on(_onStartListen); + on(_onStopListen); + on(_onDataUpdated); + } + + Future _onStartListen( + DeviceRealtimeStartListen event, + Emitter emit, + ) async { + if (_currentDeviceId == event.deviceId && state is DeviceRealtimeLoaded) { + debugPrint('⚠️ [DeviceRealtimeBloc] 已在监听该设备,跳过'); + return; + } + + emit(const DeviceRealtimeLoading()); + + final result = await _taskMessageRepository.startListening( + deviceId: event.deviceId, + ); + + result.fold( + (failure) { + emit(DeviceRealtimeError(failure.message)); + }, + (_) { + _currentDeviceId = event.deviceId; + + _realtimeSubscription?.cancel(); + _realtimeSubscription = _taskMessageRepository.realTimeMessageStream.listen( + (message) { + add(DeviceRealtimeDataUpdated(message.toJson())); + }, + onError: (error) { + debugPrint('❌ [DeviceRealtimeBloc] 实时数据流错误: $error'); + }, + ); + + debugPrint('✅ [DeviceRealtimeBloc] 开始监听设备: ${event.deviceId}'); + }, + ); + } + + void _onStopListen( + DeviceRealtimeStopListen event, + Emitter emit, + ) { + _realtimeSubscription?.cancel(); + _realtimeSubscription = null; + _taskMessageRepository.stopListening(); + _currentDeviceId = null; + + debugPrint('🔕 [DeviceRealtimeBloc] 停止监听'); + emit(const DeviceRealtimeInitial()); + } + + void _onDataUpdated( + DeviceRealtimeDataUpdated event, + Emitter emit, + ) { + final newState = DeviceRealtimeLoaded.fromRealtimeMessage(event.realtimeData); + emit(newState); + + debugPrint('📊 [DeviceRealtimeBloc] 数据更新 - 电压: ${newState.voltage}V, 电量: ${newState.battery}%'); + } + + @override + Future close() { + _realtimeSubscription?.cancel(); + _taskMessageRepository.stopListening(); + return super.close(); + } +} diff --git a/lib/features/v2/device_list/presentation/bloc/device_realtime_event.dart b/lib/features/v2/device_list/presentation/bloc/device_realtime_event.dart new file mode 100644 index 00000000..1cdc4db4 --- /dev/null +++ b/lib/features/v2/device_list/presentation/bloc/device_realtime_event.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; + +abstract class DeviceRealtimeEvent extends Equatable { + const DeviceRealtimeEvent(); + + @override + List get props => []; +} + +class DeviceRealtimeStartListen extends DeviceRealtimeEvent { + final String deviceId; + + const DeviceRealtimeStartListen(this.deviceId); + + @override + List get props => [deviceId]; +} + +class DeviceRealtimeStopListen extends DeviceRealtimeEvent { + const DeviceRealtimeStopListen(); +} + +class DeviceRealtimeDataUpdated extends DeviceRealtimeEvent { + final Map realtimeData; + + const DeviceRealtimeDataUpdated(this.realtimeData); + + @override + List get props => [realtimeData]; +} diff --git a/lib/features/v2/device_list/presentation/bloc/device_realtime_state.dart b/lib/features/v2/device_list/presentation/bloc/device_realtime_state.dart new file mode 100644 index 00000000..9356805c --- /dev/null +++ b/lib/features/v2/device_list/presentation/bloc/device_realtime_state.dart @@ -0,0 +1,146 @@ +import 'package:equatable/equatable.dart'; + +abstract class DeviceRealtimeState extends Equatable { + const DeviceRealtimeState(); + + @override + List get props => []; +} + +class DeviceRealtimeInitial extends DeviceRealtimeState { + const DeviceRealtimeInitial(); +} + +class DeviceRealtimeLoading extends DeviceRealtimeState { + const DeviceRealtimeLoading(); +} + +class DeviceRealtimeLoaded extends DeviceRealtimeState { + final Map realtimeData; + final double? voltage; + final double? battery; + final double? latitude; + final double? longitude; + final double? yaw; + final double? pitch; + final double? roll; + final double? leftMotorTemp; + final double? rightMotorTemp; + final double? chipTemp; + final double? cuttingSpeed; + final int? satelliteCnt; + final int? controlMode; + + const DeviceRealtimeLoaded({ + required this.realtimeData, + this.voltage, + this.battery, + this.latitude, + this.longitude, + this.yaw, + this.pitch, + this.roll, + this.leftMotorTemp, + this.rightMotorTemp, + this.chipTemp, + this.cuttingSpeed, + this.satelliteCnt, + this.controlMode, + }); + + factory DeviceRealtimeLoaded.fromRealtimeMessage(Map rawData) { + final data = rawData['data'] as List? ?? []; + + double? getValueByName(String name) { + final item = data.firstWhere( + (item) => item['name'] == name, + orElse: () => null, + ); + if (item == null) return null; + return double.tryParse(item['value'].toString()); + } + + int? getIntValueByName(String name) { + final value = getValueByName(name); + return value?.toInt(); + } + + return DeviceRealtimeLoaded( + realtimeData: rawData, + voltage: getValueByName('voltage'), + battery: getValueByName('battery'), + latitude: getValueByName('latitude'), + longitude: getValueByName('longitude'), + yaw: getValueByName('yaw'), + pitch: getValueByName('pitch'), + roll: getValueByName('roll'), + leftMotorTemp: getValueByName('leftMotorTemp'), + rightMotorTemp: getValueByName('rightMotorTemp'), + chipTemp: getValueByName('chipTemp'), + cuttingSpeed: getValueByName('cuttingSpeed'), + satelliteCnt: getIntValueByName('satelliteCnt'), + controlMode: getIntValueByName('controlMode'), + ); + } + + DeviceRealtimeLoaded copyWith({ + Map? realtimeData, + double? voltage, + double? battery, + double? latitude, + double? longitude, + double? yaw, + double? pitch, + double? roll, + double? leftMotorTemp, + double? rightMotorTemp, + double? chipTemp, + double? cuttingSpeed, + int? satelliteCnt, + int? controlMode, + }) { + return DeviceRealtimeLoaded( + realtimeData: realtimeData ?? this.realtimeData, + voltage: voltage ?? this.voltage, + battery: battery ?? this.battery, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + yaw: yaw ?? this.yaw, + pitch: pitch ?? this.pitch, + roll: roll ?? this.roll, + leftMotorTemp: leftMotorTemp ?? this.leftMotorTemp, + rightMotorTemp: rightMotorTemp ?? this.rightMotorTemp, + chipTemp: chipTemp ?? this.chipTemp, + cuttingSpeed: cuttingSpeed ?? this.cuttingSpeed, + satelliteCnt: satelliteCnt ?? this.satelliteCnt, + controlMode: controlMode ?? this.controlMode, + ); + } + + @override + List get props => [ + realtimeData, + voltage, + battery, + latitude, + longitude, + yaw, + pitch, + roll, + leftMotorTemp, + rightMotorTemp, + chipTemp, + cuttingSpeed, + satelliteCnt, + controlMode, + ]; +} + +class DeviceRealtimeError extends DeviceRealtimeState { + final String message; + + const DeviceRealtimeError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/main.dart b/lib/main.dart index 3cb6a0f9..45787878 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:maibu_satabot_v2/core/update/update_cubit.dart'; import 'package:maibu_satabot_v2/core/update/version_check_service.dart'; import 'package:maibu_satabot_v2/core/update/update_dialog.dart'; import 'package:maibu_satabot_v2/core/update/update_state.dart'; +import 'package:maibu_satabot_v2/core/network/mqtt/mqtt_manager.dart'; import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart'; import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart'; import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart'; @@ -26,6 +27,7 @@ import 'core/localization/locale_cubit.dart'; import 'features/auth/presentation/bloc/auth_state.dart'; import 'features/auth/presentation/bloc/login_cubit.dart'; import 'features/devices/presentation/bloc/device_status_bloc.dart'; +import 'features/devices/presentation/bloc/device_task_cubit.dart'; // 🔥 添加 DeviceTaskCubit import 'features/home/presentation/bloc/permission_request_bloc.dart'; import 'features/remote_control/presentation/bloc/remote_control_cubit.dart'; @@ -43,6 +45,11 @@ void main() async { await logger.init(); Bloc.observer = AppBlocObserver(logger); + // 🔥 初始化 MQTT 连接(异步,不阻塞启动) + MqttManager().initialize().catchError((e) { + debugPrint('⚠️ [Main] MQTT 初始化失败,但不影响应用启动: $e'); + }); + // ⚠️ 注意:不要在启动时清除补丁版本记录! // 补丁版本记录只在整包更新成功后才清除 // 如果在启动时清除,会导致差量更新后划掉App再进入时循环更新 @@ -73,7 +80,16 @@ class MyApp extends StatelessWidget { create: (_) { final user = sl().state.user; final devicesCubit = sl(); - if (user != null) devicesCubit.fetchAllDevices(user.username); + + // 🔥 修复:检查是否已有 targetDevice,如果有则不需要加载设备列表 + final remoteControlCubit = sl(); + if (user != null && remoteControlCubit.state.targetDevice == null) { + debugPrint('📱 [Main] 无 targetDevice,开始加载设备列表'); + devicesCubit.fetchAllDevices(user.username); + } else if (user != null) { + debugPrint('✅ [Main] 已有 targetDevice: ${remoteControlCubit.state.targetDevice!.deviceName},跳过设备列表加载'); + } + return devicesCubit; }, ), @@ -89,6 +105,9 @@ class MyApp extends StatelessWidget { BlocProvider( create: (_) => sl(), ), + BlocProvider( + create: (_) => sl(), + ), ], child: BlocBuilder( bloc: localeCubit, diff --git a/pubspec.lock b/pubspec.lock index 5a0ee76e..55f995b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -312,6 +312,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.8" + event_bus: + dependency: transitive + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.1" fake_async: dependency: transitive description: @@ -916,6 +924,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.5.7" + mqtt_client: + dependency: "direct main" + description: + name: mqtt_client + sha256: "41c8edd3bc8efc80c1c8ebfb40081c24d12d13085faca96b9280a624eca2d893" + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.11.11" native_toolchain_c: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b2374b3a..9c3e3d17 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -140,6 +140,7 @@ dependencies: vibration: ^3.1.8 flutter_patcher: ^0.1.2 # Add flutter_patcher here open_file: ^3.3.2 # 打开文件(安装 APK) + mqtt_client: ^10.11.11 dev_dependencies: flutter_test: