1.测试和修改了无人机在线实时信息的展示数据集的展示。使其能够全面展示。

2.添加了在无人机设备摄像头的为空时候,通过新增的接口获取无人机的摄像头的列表。
3.替换原先依照tcp推送消息绘制机器运动作业的轨迹过程,先用订阅mqtt特定话题推送的信息做动画过程的绘制。
This commit is contained in:
2026-06-24 14:44:16 +08:00
parent b7ea788298
commit d5a83c1221
7 changed files with 689 additions and 185 deletions

View File

@@ -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<String, dynamic> json) {
final entityJson = json['entity'] as Map<String, dynamic>?;
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<String, dynamic>? ?? {}),
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<String, dynamic> json) {
return ArriveLocation(
@@ -56,10 +54,7 @@ class ArriveLocation extends Equatable {
}
Map<String, dynamic> toJson() {
return {
'lat': lat,
'lng': lng,
};
return {'lat': lat, 'lng': lng};
}
@override

View File

@@ -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<TaskMessageRepository>();
class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
final NetMessageDispatcher _dispatcher;
@@ -20,6 +26,7 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
// 🔥 保存订阅引用,用于管理生命周期
StreamSubscription? _tcpSubscription;
StreamSubscription? _mqttArriveSubscription;
// 🔥 节流相关:500ms节流控制0x02数据推送频率
Timer? _throttleTimer;
@@ -27,16 +34,23 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
RunningStatusEntity? _cachedStatus;
GPSEntity? _cachedGps;
// 🔥 当前监听的设备ID
String? _currentDeviceId;
DeviceStatusBloc(this._dispatcher, {TcpClient? client})
: tcpClient = client ?? GetIt.I<TcpClient>(),
super(DeviceStatusInitial()) {
// 🔥 核心改动:直接在构造函数中建立TCP监听,类似RemoteControlCubit
_initDirectTcpListener();
// 🔥 初始化MQTT到达点监听
_initMqttArriveListener();
// 保留事件处理(用于手动重置等场景)
on<DeviceStatusReset>(_handleReset);
on<DeviceStatusLoaded>(_handleDeviceStatusLoaded);
on<PushMessageReceived>(_handlePushMessageReceived);
on<DeviceTaskArrivePointEvent>(_handleDeviceTaskArrivePointEvent);
}
// 🔥 已废弃:重新初始化TCP监听器会导致数据流中断
@@ -162,6 +176,62 @@ class DeviceStatusBloc extends Bloc<DeviceStatusEvent, DeviceStatusState> {
_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<DeviceStatusEvent, DeviceStatusState> {
}
}
// 🔥 处理MQTT到达点事件
Future<void> _handleDeviceTaskArrivePointEvent(
DeviceTaskArrivePointEvent event,
Emitter<DeviceStatusState> 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>();
devicesCubit.setArrivedLocation(event.lat!, event.lng!);
// 同时更新DeviceStatusBloc的状态
emit(DeviceStatusUpdated(_cachedStatus ?? RunningStatusEntity(), gps));
} else {
// ❌ entity为空或坐标无效 - 表示任务停止
debugPrint('🛑 [DeviceStatusBloc] 收到任务停止信号');
_logger.log('🛑 [DeviceStatusBloc] 收到任务停止信号');
final devicesCubit = GetIt.I<DevicesCubit>();
devicesCubit.finishWork();
Future.delayed(Duration(seconds: 1), () {
devicesCubit.resetWorkStatus();
});
}
}
@override
Future<void> 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();
}
}

View File

@@ -24,6 +24,22 @@ class PushMessageReceived extends DeviceStatusEvent {
class DeviceStatusReset extends DeviceStatusEvent {
@override
// TODO: implement props
List<Object?> get props => throw UnimplementedError();
List<Object?> 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<Object?> get props => [deviceId, taskId, lat, lng];
}

View File

@@ -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<TaskMessageRepository>();
class DevicesCubit extends Cubit<DevicesState> {
final GetUserDeviceUseCase _getUserDeviceUseCase;
final GetDeviceLocationUseCase _getDeviceLocationUseCase;
@@ -672,4 +676,42 @@ class DevicesCubit extends Cubit<DevicesState> {
return null;
}
}
/// 🔥 启动MQTT到达点监听(用于路径规划动画)
/// [deviceId] - 目标设备ID,即targetDevice的deviceId
Future<void> 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<void> 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');
}
}
}

View File

@@ -409,8 +409,11 @@ class _DroneStationDetailPageState extends State<DroneStationDetailPage> {
MaterialPageRoute(
builder: (context) => DroneVideoControlPage(
droneSn: detail.deviceSn,
gatewaySn: detail.gatewaySn,
// 使用无人机摄像头列表,如果为null则传空列表
cameraList: detail.droneCameraList ?? [],
// 传入网关摄像头列表作为备选视频源
gatewayCameraList: detail.gatewayCameraList ?? [],
),
),
);

View File

@@ -11,12 +11,16 @@ import '../bloc/drone_station_state.dart';
/// 无人机视频回传/远程控制页面
class DroneVideoControlPage extends StatefulWidget {
final String droneSn; // 无人机设备序列号
final List<CameraInfo>? cameraList; // 摄像头列表
final String gatewaySn; // 网关(机场)设备序列号
final List<CameraInfo>? cameraList; // 无人机摄像头列表
final List<CameraInfo>? gatewayCameraList; // 网关摄像头列表(备选)
const DroneVideoControlPage({
super.key,
required this.droneSn,
required this.gatewaySn,
this.cameraList,
this.gatewayCameraList,
});
@override
@@ -31,43 +35,73 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
String? _errorMessage;
UavLensType? _currentLensType;
CameraInfo? _currentCamera; // 当前选中的摄像头
// 备选视频源相关
bool _useBackupSource = false; // 是否使用备选视频源
List<CameraInfo> _effectiveCameraList = []; // 实际使用的摄像头列表
List<CameraInfo>? _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<DroneStationBloc>();
// 初始化事件处理器
_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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
_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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
}
};
_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<DroneVideoControlPage> {
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<CameraInfo>(
value: _currentCamera,
hint: const Text(
'选择摄像头',
style: TextStyle(fontSize: 12, color: Color(0xFF86909C)),
),
items: widget.cameraList!.map((camera) {
return DropdownMenuItem<CameraInfo>(
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<bool>(
value: _useBackupSource,
items: [
DropdownMenuItem<bool>(
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<bool>(
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<CameraInfo>(
value: _currentCamera,
hint: const Text(
'选择摄像头',
style: TextStyle(
fontSize: 12,
color: Color(0xFF86909C),
),
),
items: _effectiveCameraList.map((camera) {
return DropdownMenuItem<CameraInfo>(
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<DroneVideoControlPage> {
});
debugPrint('=== 视频流加载成功 ===');
debugPrint('URL Type: ${state.videoStream.urlType}');
if (state.videoStream.urlType == 'volc') {
_destroyRtcEngine();
_initRtcEngine(state.videoStream);
@@ -439,7 +570,9 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
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<DroneVideoControlPage> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
const SizedBox(height: 12),
const Text(
@@ -484,46 +619,68 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
),
)
: _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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
debugPrint('⚠️ 镜头类型相同,忽略操作');
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<UavLensType>>[
const PopupMenuItem<UavLensType>(value: UavLensType.wide, child: Text('广角')),
const PopupMenuItem<UavLensType>(value: UavLensType.zoom, child: Text('变焦')),
const PopupMenuItem<UavLensType>(value: UavLensType.ir, child: Text('红外')),
],
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<UavLensType>>[
const PopupMenuItem<UavLensType>(
value: UavLensType.wide,
child: Text('广角'),
),
const PopupMenuItem<UavLensType>(
value: UavLensType.zoom,
child: Text('变焦'),
),
const PopupMenuItem<UavLensType>(
value: UavLensType.ir,
child: Text('红外'),
),
],
),
),
],
@@ -582,7 +770,13 @@ class _DroneVideoControlPageState extends State<DroneVideoControlPage> {
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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
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<DroneVideoControlPage> {
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)),
),
],
),
);
}
}

View File

@@ -93,19 +93,35 @@ class _DroneOsdCardState extends State<DroneOsdCard> {
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<DroneOsdCard> {
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<DroneOsdCard> {
// 经度(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)