diff --git a/lib/core/network/mqtt/domain/entities/task_arrive_entity.dart b/lib/core/network/mqtt/domain/entities/task_arrive_entity.dart index 82392633..dacddfe3 100644 --- a/lib/core/network/mqtt/domain/entities/task_arrive_entity.dart +++ b/lib/core/network/mqtt/domain/entities/task_arrive_entity.dart @@ -3,25 +3,26 @@ import 'package:equatable/equatable.dart'; class TaskArriveEntity extends Equatable { final String type; final String deviceId; - final int taskId; + final int? taskId; final dynamic status; - final ArriveLocation entity; + final ArriveLocation? entity; const TaskArriveEntity({ required this.type, required this.deviceId, - required this.taskId, + this.taskId, this.status, - required this.entity, + this.entity, }); factory TaskArriveEntity.fromJson(Map json) { + final entityJson = json['entity'] as Map?; return TaskArriveEntity( type: json['type'] as String? ?? '', deviceId: json['deviceId'] as String? ?? '', - taskId: json['taskId'] as int? ?? 0, + taskId: json['taskId'] as int?, status: json['status'], - entity: ArriveLocation.fromJson(json['entity'] as Map? ?? {}), + entity: entityJson != null ? ArriveLocation.fromJson(entityJson) : null, ); } @@ -31,7 +32,7 @@ class TaskArriveEntity extends Equatable { 'deviceId': deviceId, 'taskId': taskId, 'status': status, - 'entity': entity.toJson(), + 'entity': entity?.toJson(), }; } @@ -43,10 +44,7 @@ class ArriveLocation extends Equatable { final double lat; final double lng; - const ArriveLocation({ - required this.lat, - required this.lng, - }); + const ArriveLocation({required this.lat, required this.lng}); factory ArriveLocation.fromJson(Map json) { return ArriveLocation( @@ -56,10 +54,7 @@ class ArriveLocation extends Equatable { } Map toJson() { - return { - 'lat': lat, - 'lng': lng, - }; + return {'lat': lat, 'lng': lng}; } @override diff --git a/lib/features/devices/presentation/bloc/device_status_bloc.dart b/lib/features/devices/presentation/bloc/device_status_bloc.dart index db1fb4a9..3c60cfb3 100644 --- a/lib/features/devices/presentation/bloc/device_status_bloc.dart +++ b/lib/features/devices/presentation/bloc/device_status_bloc.dart @@ -6,12 +6,18 @@ import 'package:get_it/get_it.dart'; import 'package:maibu_satabot_v2/features/devices/domain/entities/running_status_entity.dart'; 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/entities/task_arrive_entity.dart'; import '../../../../core/logging/i_logger_service.dart'; import '../../../../core/network/net_message_dispatcher.dart'; import '../../../../core/network/tcp/tcp_client.dart'; import 'device_status_event.dart'; import 'device_status_state.dart'; +import 'devices_cubit.dart'; + +// 定义全局的TaskMessageRepository获取方式 +TaskMessageRepository get _taskMessageRepo => GetIt.I(); class DeviceStatusBloc extends Bloc { final NetMessageDispatcher _dispatcher; @@ -20,6 +26,7 @@ class DeviceStatusBloc extends Bloc { // 🔥 保存订阅引用,用于管理生命周期 StreamSubscription? _tcpSubscription; + StreamSubscription? _mqttArriveSubscription; // 🔥 节流相关:500ms节流控制0x02数据推送频率 Timer? _throttleTimer; @@ -27,16 +34,23 @@ class DeviceStatusBloc extends Bloc { RunningStatusEntity? _cachedStatus; GPSEntity? _cachedGps; + // 🔥 当前监听的设备ID + String? _currentDeviceId; + DeviceStatusBloc(this._dispatcher, {TcpClient? client}) : tcpClient = client ?? GetIt.I(), super(DeviceStatusInitial()) { // 🔥 核心改动:直接在构造函数中建立TCP监听,类似RemoteControlCubit _initDirectTcpListener(); + // 🔥 初始化MQTT到达点监听 + _initMqttArriveListener(); + // 保留事件处理(用于手动重置等场景) on(_handleReset); on(_handleDeviceStatusLoaded); on(_handlePushMessageReceived); + on(_handleDeviceTaskArrivePointEvent); } // 🔥 已废弃:重新初始化TCP监听器会导致数据流中断 @@ -162,6 +176,62 @@ class DeviceStatusBloc extends Bloc { _logger.log('✅ [DeviceStatusBloc] 直接TCP监听器已建立完成'); } + // 🔥 初始化MQTT到达点监听 + void _initMqttArriveListener() { + debugPrint('🔗 [DeviceStatusBloc] 初始化MQTT到达点监听器'); + _logger.log('🔗 [DeviceStatusBloc] 初始化MQTT到达点监听器'); + + _mqttArriveSubscription = _taskMessageRepo.taskArriveStream.listen( + (TaskArriveEntity arrive) { + debugPrint( + '📍 [DeviceStatusBloc] 收到MQTT到达点消息: type=${arrive.type}, deviceId=${arrive.deviceId}', + ); + _logger.log( + '📍 [DeviceStatusBloc] 收到MQTT到达点消息: type=${arrive.type}, deviceId=${arrive.deviceId}', + ); + + // 过滤出路径规划到达点消息 + if (arrive.type == 'device_task_arrive_point') { + // 检查设备ID是否匹配当前监听的设备 + if (_currentDeviceId != null && arrive.deviceId != _currentDeviceId) { + debugPrint( + '⚠️ [DeviceStatusBloc] 设备ID不匹配,跳过 - 当前:$_currentDeviceId, 收到:${arrive.deviceId}', + ); + return; + } + + // 提取坐标(支持空entity场景) + double? lat = arrive.entity?.lat; + double? lng = arrive.entity?.lng; + + // 发送事件到Bloc + add( + DeviceTaskArrivePointEvent( + deviceId: arrive.deviceId, + taskId: arrive.taskId, + lat: lat, + lng: lng, + ), + ); + } + }, + onError: (e) { + debugPrint('❌ [DeviceStatusBloc] MQTT到达点监听错误: $e'); + _logger.log('❌ [DeviceStatusBloc] MQTT到达点监听错误: $e'); + }, + ); + + debugPrint('✅ [DeviceStatusBloc] MQTT到达点监听器已建立完成'); + _logger.log('✅ [DeviceStatusBloc] MQTT到达点监听器已建立完成'); + } + + // 🔥 设置当前监听的设备ID(用于过滤MQTT消息) + void setListeningDeviceId(String deviceId) { + debugPrint('📱 [DeviceStatusBloc] 设置监听设备ID: $deviceId'); + _logger.log('📱 [DeviceStatusBloc] 设置监听设备ID: $deviceId'); + _currentDeviceId = deviceId; + } + // 🔥 节流发射:500ms到期后发射缓存的最新数据 void _emitCachedStatus() { if (_cachedStatus != null && _cachedGps != null && !isClosed) { @@ -228,17 +298,59 @@ class DeviceStatusBloc extends Bloc { } } + // 🔥 处理MQTT到达点事件 + Future _handleDeviceTaskArrivePointEvent( + DeviceTaskArrivePointEvent event, + Emitter emit, + ) async { + debugPrint( + '📍 [DeviceStatusBloc] 处理到达点事件: deviceId=${event.deviceId}, taskId=${event.taskId}, lat=${event.lat}, lng=${event.lng}', + ); + _logger.log( + '📍 [DeviceStatusBloc] 处理到达点事件: deviceId=${event.deviceId}, taskId=${event.taskId}, lat=${event.lat}, lng=${event.lng}', + ); + + if (event.lat != null && event.lng != null && event.lat != 0.0) { + // ✅ 收到有效到达点 - 更新位置状态 + final gps = GPSEntity(event.lat!, event.lng!); + + // 发送到DevicesCubit更新到达位置(触发地图轨迹更新) + final devicesCubit = GetIt.I(); + devicesCubit.setArrivedLocation(event.lat!, event.lng!); + + // 同时更新DeviceStatusBloc的状态 + emit(DeviceStatusUpdated(_cachedStatus ?? RunningStatusEntity(), gps)); + } else { + // ❌ entity为空或坐标无效 - 表示任务停止 + debugPrint('🛑 [DeviceStatusBloc] 收到任务停止信号'); + _logger.log('🛑 [DeviceStatusBloc] 收到任务停止信号'); + + final devicesCubit = GetIt.I(); + devicesCubit.finishWork(); + Future.delayed(Duration(seconds: 1), () { + devicesCubit.resetWorkStatus(); + }); + } + } + @override Future close() { - debugPrint('🚫 [DeviceStatusBloc] 页面退出,仅取消TCP订阅(不关闭BLoC)'); - _logger.log('🚫 [DeviceStatusBloc] 页面退出,仅取消TCP订阅(不关闭BLoC)'); - //_tcpSubscription?.cancel(); + debugPrint('🚫 [DeviceStatusBloc] 页面退出,清理资源'); + _logger.log('🚫 [DeviceStatusBloc] 页面退出,清理资源'); + + // 🔥 清理MQTT订阅 + _mqttArriveSubscription?.cancel(); + _mqttArriveSubscription = null; + // 🔥 清理节流timer和缓存 _throttleTimer?.cancel(); _throttleTimer = null; _cachedStatus = null; _cachedGps = null; - // 🔥 关键修复:不调用 super.close(),保持 BLoC 活跃 + + // 🔥 重置设备ID + _currentDeviceId = null; + return Future.value(); } } diff --git a/lib/features/devices/presentation/bloc/device_status_event.dart b/lib/features/devices/presentation/bloc/device_status_event.dart index ec1780de..0620506a 100644 --- a/lib/features/devices/presentation/bloc/device_status_event.dart +++ b/lib/features/devices/presentation/bloc/device_status_event.dart @@ -24,6 +24,22 @@ class PushMessageReceived extends DeviceStatusEvent { class DeviceStatusReset extends DeviceStatusEvent { @override - // TODO: implement props - List get props => throw UnimplementedError(); + List get props => []; +} + +class DeviceTaskArrivePointEvent extends DeviceStatusEvent { + final String deviceId; + final int? taskId; + final double? lat; + final double? lng; + + const DeviceTaskArrivePointEvent({ + required this.deviceId, + this.taskId, + this.lat, + this.lng, + }); + + @override + List get props => [deviceId, taskId, lat, lng]; } \ No newline at end of file diff --git a/lib/features/devices/presentation/bloc/devices_cubit.dart b/lib/features/devices/presentation/bloc/devices_cubit.dart index e8252c97..6814017c 100644 --- a/lib/features/devices/presentation/bloc/devices_cubit.dart +++ b/lib/features/devices/presentation/bloc/devices_cubit.dart @@ -17,6 +17,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/network/tcp/tcp_client.dart'; +import '../../../../core/network/mqtt/domain/repositories/task_message_repository.dart'; import '../../data/models/device_add_path_point_model.dart'; import '../../data/models/device_work_area_param_model.dart'; import '../../domain/usecases/bind_device_usecase.dart'; @@ -30,6 +31,9 @@ import 'device_status_bloc.dart'; import 'device_status_event.dart'; import 'devices_state.dart'; +// 定义全局的TaskMessageRepository获取方式 +TaskMessageRepository get _taskMessageRepo => GetIt.I(); + class DevicesCubit extends Cubit { final GetUserDeviceUseCase _getUserDeviceUseCase; final GetDeviceLocationUseCase _getDeviceLocationUseCase; @@ -672,4 +676,42 @@ class DevicesCubit extends Cubit { return null; } } + + /// 🔥 启动MQTT到达点监听(用于路径规划动画) + /// [deviceId] - 目标设备ID,即targetDevice的deviceId + Future startListeningMqttArrive({required String deviceId}) async { + debugPrint('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId'); + _logger.logWithLevel('📡 [DevicesCubit] 启动MQTT到达点监听 - deviceId: $deviceId'); + + try { + // 启动MQTT订阅 + await _taskMessageRepo.startListening(deviceId: deviceId); + + // 设置DeviceStatusBloc监听的设备ID + _deviceStatusBloc.setListeningDeviceId(deviceId); + + debugPrint('✅ [DevicesCubit] MQTT到达点监听已启动'); + _logger.logWithLevel('✅ [DevicesCubit] MQTT到达点监听已启动'); + } catch (e) { + debugPrint('❌ [DevicesCubit] 启动MQTT监听失败: $e'); + _logger.logWithLevel('❌ [DevicesCubit] 启动MQTT监听失败: $e'); + rethrow; + } + } + + /// 🔥 停止MQTT到达点监听 + Future stopListeningMqttArrive() async { + debugPrint('🛑 [DevicesCubit] 停止MQTT到达点监听'); + _logger.logWithLevel('🛑 [DevicesCubit] 停止MQTT到达点监听'); + + try { + await _taskMessageRepo.stopListening(); + _deviceStatusBloc.setListeningDeviceId(''); + debugPrint('✅ [DevicesCubit] MQTT到达点监听已停止'); + _logger.logWithLevel('✅ [DevicesCubit] MQTT到达点监听已停止'); + } catch (e) { + debugPrint('❌ [DevicesCubit] 停止MQTT监听失败: $e'); + _logger.logWithLevel('❌ [DevicesCubit] 停止MQTT监听失败: $e'); + } + } } diff --git a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart index f94c3876..2a374af0 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart @@ -409,8 +409,11 @@ class _DroneStationDetailPageState extends State { MaterialPageRoute( builder: (context) => DroneVideoControlPage( droneSn: detail.deviceSn, + gatewaySn: detail.gatewaySn, // 使用无人机摄像头列表,如果为null则传空列表 cameraList: detail.droneCameraList ?? [], + // 传入网关摄像头列表作为备选视频源 + gatewayCameraList: detail.gatewayCameraList ?? [], ), ), ); diff --git a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart index eb58e031..3c7c1a31 100644 --- a/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart +++ b/lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart @@ -11,12 +11,16 @@ import '../bloc/drone_station_state.dart'; /// 无人机视频回传/远程控制页面 class DroneVideoControlPage extends StatefulWidget { final String droneSn; // 无人机设备序列号 - final List? cameraList; // 摄像头列表 + final String gatewaySn; // 网关(机场)设备序列号 + final List? cameraList; // 无人机摄像头列表 + final List? gatewayCameraList; // 网关摄像头列表(备选) const DroneVideoControlPage({ super.key, required this.droneSn, + required this.gatewaySn, this.cameraList, + this.gatewayCameraList, }); @override @@ -31,43 +35,73 @@ class _DroneVideoControlPageState extends State { String? _errorMessage; UavLensType? _currentLensType; CameraInfo? _currentCamera; // 当前选中的摄像头 - + + // 备选视频源相关 + bool _useBackupSource = false; // 是否使用备选视频源 + List _effectiveCameraList = []; // 实际使用的摄像头列表 + List? _backupCameraList; // 备选摄像头列表(网关摄像头) + // 火山引擎 RTC volc.RTCEngine? _rtcEngine; volc.RTCRoom? _rtcRoom; volc.RTCViewContext? _remoteRenderContext; String? _remoteUserId; - + // 事件处理器(注意:使用 I 前缀的接口) - final volc.IRTCEngineEventHandler _engineEventHandler = volc.IRTCEngineEventHandler(); - final volc.IRTCRoomEventHandler _roomEventHandler = volc.IRTCRoomEventHandler(); + final volc.IRTCEngineEventHandler _engineEventHandler = + volc.IRTCEngineEventHandler(); + final volc.IRTCRoomEventHandler _roomEventHandler = + volc.IRTCRoomEventHandler(); @override void initState() { super.initState(); _bloc = sl(); - + // 初始化事件处理器 _initVolcEventHandlers(); - + // 打印接收到的参数 debugPrint('=== 视频控制页面接收参数 ==='); debugPrint('droneSn: ${widget.droneSn}'); + debugPrint('gatewaySn: ${widget.gatewaySn}'); debugPrint('cameraList: ${widget.cameraList}'); + debugPrint('gatewayCameraList: ${widget.gatewayCameraList}'); if (widget.cameraList != null) { for (var camera in widget.cameraList!) { - debugPrint(' - cameraIndex: ${camera.cameraIndex}'); + debugPrint(' - 无人机摄像头: ${camera.cameraIndex}'); + } + } + if (widget.gatewayCameraList != null) { + for (var camera in widget.gatewayCameraList!) { + debugPrint(' - 网关摄像头: ${camera.cameraIndex}'); } } debugPrint('===========================\n'); - + + // 保存备选摄像头列表 + _backupCameraList = widget.gatewayCameraList; + + // 确定实际使用的摄像头列表 + _effectiveCameraList = widget.cameraList ?? []; + + // 检查是否需要使用备选视频源 + if (_effectiveCameraList.isEmpty && + widget.gatewayCameraList != null && + widget.gatewayCameraList!.isNotEmpty) { + debugPrint('⚠️ 无人机摄像头列表为空,自动切换到备选视频源(网关摄像头)'); + _useBackupSource = true; + _effectiveCameraList = widget.gatewayCameraList!; + } + // 默认选择第一个摄像头 - _currentCamera = widget.cameraList?.isNotEmpty == true - ? widget.cameraList!.first + _currentCamera = _effectiveCameraList.isNotEmpty + ? _effectiveCameraList.first : null; - + debugPrint('默认选择的摄像头: ${_currentCamera?.cameraIndex}'); - + debugPrint('当前视频源: ${_useBackupSource ? "备选(网关)" : "主视频源(无人机)"}'); + // 默认加载广角镜头 if (_currentCamera != null) { _loadVideoStream(UavLensType.wide); @@ -113,6 +147,7 @@ class _DroneVideoControlPageState extends State { debugPrint('当前摄像头: ${_currentCamera!.cameraIndex}'); debugPrint('新镜头类型: ${lensType.name} (${_getLensTypeName(lensType)})'); debugPrint('旧镜头类型: ${_currentLensType?.name ?? "none"}'); + debugPrint('视频源: ${_useBackupSource ? "备选(网关)" : "主视频源(无人机)"}'); debugPrint('==================\n'); setState(() { @@ -121,9 +156,14 @@ class _DroneVideoControlPageState extends State { _currentLensType = lensType; }); + // 根据是否使用备选视频源决定使用哪个 SN + final effectiveSn = _useBackupSource ? widget.gatewaySn : widget.droneSn; + + debugPrint('📤 请求视频流 - SN: $effectiveSn, 摄像头: ${_currentCamera!.cameraIndex}'); + _bloc.add( UavVideoStreamLoad( - sn: widget.droneSn, + sn: effectiveSn, cameraIndex: _currentCamera!.cameraIndex, lensType: lensType, qualityType: VideoQualityType.adaptive, @@ -137,21 +177,50 @@ class _DroneVideoControlPageState extends State { if (camera.cameraIndex == _currentCamera?.cameraIndex) { return; // 如果选择的摄像头与当前相同,不执行操作 } - + setState(() { _currentCamera = camera; }); - + // 重新加载视频流 _loadVideoStream(_currentLensType ?? UavLensType.wide); } + /// 切换备选视频源 + void _toggleBackupSource(bool useBackup) { + debugPrint('=== 🔄 切换视频源 ==='); + debugPrint('切换到: ${useBackup ? "备选(网关)" : "主视频源(无人机)"}'); + + setState(() { + _useBackupSource = useBackup; + // 更新实际使用的摄像头列表 + _effectiveCameraList = useBackup + ? (_backupCameraList ?? []) + : (widget.cameraList ?? []); + // 选择新列表的第一个摄像头 + _currentCamera = _effectiveCameraList.isNotEmpty ? _effectiveCameraList.first : null; + }); + + debugPrint('新摄像头列表长度: ${_effectiveCameraList.length}'); + debugPrint('选中的摄像头: ${_currentCamera?.cameraIndex}'); + + // 重新加载视频流 + if (_currentCamera != null) { + _loadVideoStream(_currentLensType ?? UavLensType.wide); + } else { + setState(() { + _errorMessage = useBackup ? '备选视频源没有可用摄像头' : '没有可用的摄像头'; + _isLoading = false; + }); + } + } + // 初始化火山引擎事件处理器 void _initVolcEventHandlers() { _engineEventHandler.onWarning = (volc.WarningCode code) { debugPrint('⚠️ Volc Warning: $code'); }; - + _engineEventHandler.onError = (volc.ErrorCode code) { debugPrint('❌ Volc Error: $code'); if (mounted) { @@ -162,37 +231,35 @@ class _DroneVideoControlPageState extends State { } }; - _engineEventHandler.onFirstRemoteVideoFrameDecoded = ( - String streamId, - volc.StreamInfo streamInfo, - volc.VideoFrameInfo frameInfo, - ) { - debugPrint('✅✅✅ Volc 第一帧视频解码完成!'); - debugPrint(' streamId: $streamId, userId: ${streamInfo.userId}'); - - if (streamInfo.userId.isNotEmpty && mounted) { - setState(() { - _remoteUserId = streamInfo.userId; - _remoteRenderContext = volc.RTCViewContext.remoteContext( - roomId: _videoStream?.roomId ?? '', - userId: streamInfo.userId, - streamId: streamId, - ); - _isLoading = false; - }); - } - }; + _engineEventHandler.onFirstRemoteVideoFrameDecoded = + ( + String streamId, + volc.StreamInfo streamInfo, + volc.VideoFrameInfo frameInfo, + ) { + debugPrint('✅✅✅ Volc 第一帧视频解码完成!'); + debugPrint(' streamId: $streamId, userId: ${streamInfo.userId}'); - _roomEventHandler.onUserPublishStreamVideo = ( - String userId, - volc.StreamInfo streamInfo, - bool isPublish, - ) { - debugPrint('📹 Volc 远端用户 $userId 视频流状态: $isPublish'); - if (isPublish && mounted && _remoteUserId == null) { - debugPrint('⏳ 检测到 Volc 视频流推送,等待第一帧解码...'); - } - }; + if (streamInfo.userId.isNotEmpty && mounted) { + setState(() { + _remoteUserId = streamInfo.userId; + _remoteRenderContext = volc.RTCViewContext.remoteContext( + roomId: _videoStream?.roomId ?? '', + userId: streamInfo.userId, + streamId: streamId, + ); + _isLoading = false; + }); + } + }; + + _roomEventHandler.onUserPublishStreamVideo = + (String userId, volc.StreamInfo streamInfo, bool isPublish) { + debugPrint('📹 Volc 远端用户 $userId 视频流状态: $isPublish'); + if (isPublish && mounted && _remoteUserId == null) { + debugPrint('⏳ 检测到 Volc 视频流推送,等待第一帧解码...'); + } + }; _roomEventHandler.onUserLeave = (String userId, int reason) { debugPrint('👋 Volc 用户离开: $userId'); @@ -305,36 +372,100 @@ class _DroneVideoControlPageState extends State { color: Color(0xFF1D2129), ), ), - if (widget.cameraList != null && widget.cameraList!.isNotEmpty) + if (_effectiveCameraList.isNotEmpty || _backupCameraList?.isNotEmpty == true) Padding( padding: const EdgeInsets.only(top: 4), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _currentCamera, - hint: const Text( - '选择摄像头', - style: TextStyle(fontSize: 12, color: Color(0xFF86909C)), - ), - items: widget.cameraList!.map((camera) { - return DropdownMenuItem( - value: camera, - child: Text( - camera.cameraIndex, - style: const TextStyle(fontSize: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 备选视频源切换按钮 + if (_backupCameraList?.isNotEmpty == true) + Container( + margin: const EdgeInsets.only(right: 8), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _useBackupSource, + items: [ + DropdownMenuItem( + value: false, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.flight, size: 16, color: Color(0xFF165DFF)), + SizedBox(width: 4), + Text('无人机', style: TextStyle(fontSize: 12)), + ], + ), + ), + DropdownMenuItem( + value: true, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.home_work, size: 16, color: Color(0xFFFF7D00)), + SizedBox(width: 4), + Text('备选', style: TextStyle(fontSize: 12)), + ], + ), + ), + ], + onChanged: (bool? useBackup) { + if (useBackup != null && useBackup != _useBackupSource) { + _toggleBackupSource(useBackup); + } + }, + isDense: true, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF165DFF), + ), + ), ), - ); - }).toList(), - onChanged: (CameraInfo? newCamera) { - if (newCamera != null) { - _switchCamera(newCamera); - } - }, - isDense: true, - style: const TextStyle( - fontSize: 12, - color: Color(0xFF165DFF), + ), + // 摄像头选择下拉框 + DropdownButtonHideUnderline( + child: DropdownButton( + value: _currentCamera, + hint: const Text( + '选择摄像头', + style: TextStyle( + fontSize: 12, + color: Color(0xFF86909C), + ), + ), + items: _effectiveCameraList.map((camera) { + return DropdownMenuItem( + value: camera, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _useBackupSource ? Icons.home_work : Icons.videocam, + size: 16, + color: _useBackupSource ? const Color(0xFFFF7D00) : const Color(0xFF165DFF), + ), + const SizedBox(width: 4), + Text( + camera.cameraIndex, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ); + }).toList(), + onChanged: (CameraInfo? newCamera) { + if (newCamera != null) { + _switchCamera(newCamera); + } + }, + isDense: true, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF165DFF), + ), + ), ), - ), + ], ), ), ], @@ -355,7 +486,7 @@ class _DroneVideoControlPageState extends State { }); debugPrint('=== 视频流加载成功 ==='); debugPrint('URL Type: ${state.videoStream.urlType}'); - + if (state.videoStream.urlType == 'volc') { _destroyRtcEngine(); _initRtcEngine(state.videoStream); @@ -439,7 +570,9 @@ class _DroneVideoControlPageState extends State { child: Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( - color: isSelected ? const Color(0xFF165DFF) : const Color(0xFFF2F3F5), + color: isSelected + ? const Color(0xFF165DFF) + : const Color(0xFFF2F3F5), borderRadius: BorderRadius.circular(8), ), child: Text( @@ -473,7 +606,9 @@ class _DroneVideoControlPageState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), ), const SizedBox(height: 12), const Text( @@ -484,46 +619,68 @@ class _DroneVideoControlPageState extends State { ), ) : _errorMessage != null - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, color: Colors.white, size: 48), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - _errorMessage!, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14, color: Colors.white), - ), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => _loadVideoStream(_currentLensType ?? UavLensType.wide), - style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF165DFF)), - child: const Text('重试'), - ), - ], + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Colors.white, + size: 48, ), - ) - : _videoStream != null && _remoteRenderContext != null - ? volc.RTCSurfaceView( - context: _remoteRenderContext!, - renderMode: volc.VideoRenderMode.fit, - ) - : _videoStream != null - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.videocam_off, size: 48, color: Colors.grey), - const SizedBox(height: 12), - const Text('等待视频流推送...', style: TextStyle(fontSize: 14, color: Colors.white)), - ], - ), - ) - : Image.asset('assets/images/xunjian.png', fit: BoxFit.cover, width: double.infinity), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => _loadVideoStream( + _currentLensType ?? UavLensType.wide, + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF165DFF), + ), + child: const Text('重试'), + ), + ], + ), + ) + : _videoStream != null && _remoteRenderContext != null + ? volc.RTCSurfaceView( + context: _remoteRenderContext!, + renderMode: volc.VideoRenderMode.fit, + ) + : _videoStream != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.videocam_off, + size: 48, + color: Colors.grey, + ), + const SizedBox(height: 12), + const Text( + '等待视频流推送...', + style: TextStyle(fontSize: 14, color: Colors.white), + ), + ], + ), + ) + : Image.asset( + 'assets/images/xunjian.png', + fit: BoxFit.cover, + width: double.infinity, + ), ), ), Positioned( @@ -531,9 +688,23 @@ class _DroneVideoControlPageState extends State { left: 12, child: Row( children: [ - Container(width: 8, height: 8, decoration: const BoxDecoration(color: Color(0xFFF53F3F), shape: BoxShape.circle)), + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFF53F3F), + shape: BoxShape.circle, + ), + ), const SizedBox(width: 6), - const Text('REC 00:12:36', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w500)), + const Text( + 'REC 00:12:36', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), ], ), ), @@ -550,9 +721,16 @@ class _DroneVideoControlPageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(_getLensTypeName(_currentLensType ?? UavLensType.wide), style: const TextStyle(fontSize: 12, color: Colors.white)), + Text( + _getLensTypeName(_currentLensType ?? UavLensType.wide), + style: const TextStyle(fontSize: 12, color: Colors.white), + ), const SizedBox(width: 4), - const Icon(Icons.arrow_drop_down, color: Colors.white, size: 16), + const Icon( + Icons.arrow_drop_down, + color: Colors.white, + size: 16, + ), ], ), ), @@ -565,11 +743,21 @@ class _DroneVideoControlPageState extends State { debugPrint('⚠️ 镜头类型相同,忽略操作'); } }, - itemBuilder: (BuildContext context) => >[ - const PopupMenuItem(value: UavLensType.wide, child: Text('广角')), - const PopupMenuItem(value: UavLensType.zoom, child: Text('变焦')), - const PopupMenuItem(value: UavLensType.ir, child: Text('红外')), - ], + itemBuilder: (BuildContext context) => + >[ + const PopupMenuItem( + value: UavLensType.wide, + child: Text('广角'), + ), + const PopupMenuItem( + value: UavLensType.zoom, + child: Text('变焦'), + ), + const PopupMenuItem( + value: UavLensType.ir, + child: Text('红外'), + ), + ], ), ), ], @@ -582,7 +770,13 @@ class _DroneVideoControlPageState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), - boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))], + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], ), child: Row( children: [ @@ -603,9 +797,19 @@ class _DroneVideoControlPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF86909C))), + Text( + label, + style: const TextStyle(fontSize: 12, color: Color(0xFF86909C)), + ), const SizedBox(height: 4), - Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF1D2129))), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), ], ), ); @@ -617,12 +821,25 @@ class _DroneVideoControlPageState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), - boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))], + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('AI 识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1D2129))), + const Text( + 'AI 识别结果', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF1D2129), + ), + ), const SizedBox(height: 12), _buildAIResultItem('热斑疑似', '3 处'), const SizedBox(height: 8), @@ -637,10 +854,22 @@ class _DroneVideoControlPageState extends State { onTap: () {}, child: Row( children: [ - const Icon(Icons.warning_amber_rounded, color: Color(0xFFFF7D00), size: 20), + const Icon( + Icons.warning_amber_rounded, + color: Color(0xFFFF7D00), + size: 20, + ), const SizedBox(width: 8), - Expanded(child: Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)))), - Text(count, style: const TextStyle(fontSize: 14, color: Color(0xFF4E5969))), + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 14, color: Color(0xFF1D2129)), + ), + ), + Text( + count, + style: const TextStyle(fontSize: 14, color: Color(0xFF4E5969)), + ), const SizedBox(width: 4), const Icon(Icons.chevron_right, color: Color(0xFF86909C), size: 20), ], @@ -657,11 +886,21 @@ class _DroneVideoControlPageState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), - boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))], + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], ), child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: Image.asset('assets/images/xunjian.png', fit: BoxFit.cover, width: double.infinity), + child: Image.asset( + 'assets/images/xunjian.png', + fit: BoxFit.cover, + width: double.infinity, + ), ), ), ), @@ -672,16 +911,69 @@ class _DroneVideoControlPageState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), - boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))], + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], ), child: Stack( alignment: Alignment.center, children: [ - Container(width: 40, height: 40, decoration: BoxDecoration(color: const Color(0xFFC9CDD4), shape: BoxShape.circle)), - Positioned(top: 16, child: IconButton(icon: const Icon(Icons.arrow_drop_up, size: 32, color: Color(0xFF4E5969)), onPressed: () {})), - Positioned(bottom: 16, child: IconButton(icon: const Icon(Icons.arrow_drop_down, size: 32, color: Color(0xFF4E5969)), onPressed: () {})), - Positioned(left: 16, child: IconButton(icon: const Icon(Icons.arrow_left, size: 32, color: Color(0xFF4E5969)), onPressed: () {})), - Positioned(right: 16, child: IconButton(icon: const Icon(Icons.arrow_right, size: 32, color: Color(0xFF4E5969)), onPressed: () {})), + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFC9CDD4), + shape: BoxShape.circle, + ), + ), + Positioned( + top: 16, + child: IconButton( + icon: const Icon( + Icons.arrow_drop_up, + size: 32, + color: Color(0xFF4E5969), + ), + onPressed: () {}, + ), + ), + Positioned( + bottom: 16, + child: IconButton( + icon: const Icon( + Icons.arrow_drop_down, + size: 32, + color: Color(0xFF4E5969), + ), + onPressed: () {}, + ), + ), + Positioned( + left: 16, + child: IconButton( + icon: const Icon( + Icons.arrow_left, + size: 32, + color: Color(0xFF4E5969), + ), + onPressed: () {}, + ), + ), + Positioned( + right: 16, + child: IconButton( + icon: const Icon( + Icons.arrow_right, + size: 32, + color: Color(0xFF4E5969), + ), + onPressed: () {}, + ), + ), ], ), ), @@ -696,7 +988,13 @@ class _DroneVideoControlPageState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), - boxShadow: const [BoxShadow(color: Color(0x0D000000), blurRadius: 8, offset: Offset(0, 2))], + boxShadow: const [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, @@ -717,10 +1015,12 @@ class _DroneVideoControlPageState extends State { children: [ Icon(icon, size: 28, color: const Color(0xFF4E5969)), const SizedBox(height: 4), - Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF4E5969))), + Text( + label, + style: const TextStyle(fontSize: 12, color: Color(0xFF4E5969)), + ), ], ), ); } } - diff --git a/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart b/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart index 39937c8a..198797c0 100644 --- a/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart +++ b/lib/features/v2/device_list/presentation/widgets/drone_osd_card.dart @@ -93,19 +93,35 @@ class _DroneOsdCardState extends State { final data = osd.rawData; // 解析嵌套的 JSON 结构 - final droneData = data['data'] is Map - ? (data['data'] as Map)['drone'] - : null; - if (droneData == null || droneData is! Map) { - debugPrint('⚠️ [DroneOsdCard] 无法解析 drone 数据'); + // 优先级:先尝试 drone 字段,如果不存在则使用 host 字段 + final dataMap = data['data'] is Map ? data['data'] as Map : null; + Map? droneData; + if (dataMap != null) { + // 优先使用 drone 字段 + if (dataMap['drone'] is Map) { + droneData = dataMap['drone'] as Map; + } else if (dataMap['host'] is Map) { + // 如果没有 drone 字段,尝试 host 字段(机场设备的数据结构) + droneData = dataMap['host'] as Map; + } + } + + if (droneData == null) { + debugPrint('⚠️ [DroneOsdCard] 无法解析 drone 或 host 数据'); + debugPrint('📋 [DroneOsdCard] 原始数据结构: ${data.keys.toList()}'); + if (dataMap != null) { + debugPrint('📋 [DroneOsdCard] data 结构: ${dataMap.keys.toList()}'); + } return; } - debugPrint('📊 [DroneOsdCard] drone 数据键: ${droneData.keys.toList()}'); + debugPrint('📊 [DroneOsdCard] 数据键: ${droneData.keys.toList()}'); // ========== 1. 基础飞行信息 ========== - // 无人机高度(height) - double? height = (droneData['height'] as num?)?.toDouble(); + // 无人机高度(height / altitude) + double? height = + (droneData['height'] as num?)?.toDouble() ?? + (droneData['altitude'] as num?)?.toDouble(); // 飞行速度(ground_speed) double? groundSpeed = (droneData['ground_speed'] as num?)?.toDouble(); @@ -117,18 +133,32 @@ class _DroneOsdCardState extends State { int? flightTime = droneData['flight_time'] as int?; // ========== 2. 电池信息 ========== - // 电量百分比(battery_percent) - int? batteryPercent = droneData['battery_percent'] as int?; + // 电量百分比 - 支持两种结构:battery_percent 或 battery.capacity_percent + final batteryMap = droneData['battery'] as Map?; + int? batteryPercent = + droneData['battery_percent'] as int? ?? + batteryMap?['capacity_percent'] as int?; - // 电池电压(battery_voltage) + // 电池电压(battery_voltage 或 battery.batteries[0].voltage) double? batteryVoltage = (droneData['battery_voltage'] as num?)?.toDouble(); + if (batteryVoltage == null && batteryMap != null) { + final batteries = batteryMap['batteries'] as List?; + if (batteries != null && batteries.isNotEmpty) { + final firstBattery = batteries[0] as Map?; + batteryVoltage = (firstBattery?['voltage'] as num?)?.toDouble(); + } + } - // 电池电流(battery_current) - double? batteryCurrent = (droneData['battery_current'] as num?)?.toDouble(); - - // 电池温度(battery_temperature) + // 电池温度(battery_temperature 或 battery.batteries[0].temperature) double? batteryTemp = (droneData['battery_temperature'] as num?) ?.toDouble(); + if (batteryTemp == null && batteryMap != null) { + final batteries = batteryMap['batteries'] as List?; + if (batteries != null && batteries.isNotEmpty) { + final firstBattery = batteries[0] as Map?; + batteryTemp = (firstBattery?['temperature'] as num?)?.toDouble(); + } + } // ========== 3. 位置与姿态 ========== // 纬度(latitude) @@ -137,14 +167,20 @@ class _DroneOsdCardState extends State { // 经度(longitude) double? longitude = (droneData['longitude'] as num?)?.toDouble(); - // 航向角(heading) - double? heading = (droneData['heading'] as num?)?.toDouble(); + // 航向角(heading / attitude_head) + double? heading = + (droneData['heading'] as num?)?.toDouble() ?? + (droneData['attitude_head'] as num?)?.toDouble(); - // 俯仰角(pitch) - double? pitch = (droneData['pitch'] as num?)?.toDouble(); + // 俯仰角(pitch / attitude_pitch) + double? pitch = + (droneData['pitch'] as num?)?.toDouble() ?? + (droneData['attitude_pitch'] as num?)?.toDouble(); - // 横滚角(roll) - double? roll = (droneData['roll'] as num?)?.toDouble(); + // 横滚角(roll / attitude_roll) + double? roll = + (droneData['roll'] as num?)?.toDouble() ?? + (droneData['attitude_roll'] as num?)?.toDouble(); // ========== 4. GPS 状态 ========== // GPS卫星数(gps_satellites)