QGroundControl
MavSDK和QGC航点规划兼容性问题的解决方案
1. 问题背景
1.1 QGC 5.0.6 稳定版航点管理问题
版本兼容性说明
本解决方案针对以下版本进行了测试和优化:
QGroundControl (QGC) 5.0.6 稳定版在航点管理方面存在一些已知问题,这些问题主要影响无人机任务的规划和执行:
主要问题表现
航点数量异常增长
- 用户上传5个航点,QGC显示15-20个航点
- 系统自动生成额外的航点,导致任务执行异常
- 航点序列号不连续,影响任务逻辑
航点执行异常
- 无人机在航点处"徘徊"不继续执行
- 航点接受半径设置无效
- 任务执行顺序混乱
界面显示问题
- 航点列表显示不准确
- 航点属性显示错误
- 任务状态更新延迟
问题影响
- 任务可靠性下降:航点数量异常导致任务执行不可预测
- 操作效率降低:需要手动清理多余航点
- 安全风险增加:航点执行异常可能导致飞行安全问题
1.2 MAVSDK-Python MissionItem 与 QGC 兼容性问题
MAVSDK-Python 的 MissionItem 数据结构与 QGC 的航点解析机制之间存在兼容性问题,主要体现在数据结构差异和协议层面问题两个方面。在数据结构方面,MAVSDK-Python 使用 latitude_deg,longitude_deg 字段格式,而 QGC 期望 lat,lng 格式,同时 MAVSDK-Python 使用枚举类型(如 CameraAction,VehicleAction),而 QGC 期望字符串或数值,导致类型转换失败和解析错误。此外,MAVSDK-Python 的参数范围与 QGC 期望不匹配,超出范围的参数被 QGC 忽略或错误处理。
在协议层面,MAVSDK-Python 使用 MISSION_ITEM_INT 消息格式,但 QGC 5.0.6 对某些字段处理不当,存在消息序列号管理问题。同时,经纬度精度处理存在差异,高度参考系统不一致,坐标系转换错误导致航点位置计算偏差。
1.3 航点数量异常增长的根本原因分析
航点数量异常增长主要由三个核心问题导致:QGC 5.0.6 在解析航点时自动生成额外的航点,系统认为某些航点需要"连接"或"优化",但自动生成的航点没有正确的序列号;QGC 对 MISSION_ITEM_INT 消息解析不完整,某些字段被错误解释为新的航点,导致消息序列号管理混乱;上传新任务前没有完全清除旧任务,新旧航点混合导致数量异常,任务状态管理不当。
在技术实现层面,QGC 5.0.6 的解析逻辑存在缺陷,当遇到某些特殊字段时会错误地创建新航点,同时 QGC 期望连续的序列号,但 MAVSDK-Python 生成的序列号可能不连续,导致 QGC 认为有"缺失"的航点。此外,经纬度精度处理不当和坐标系转换算法错误导致航点位置计算偏差。
解决方案需要采用 MissionRaw 接口直接使用 MISSION_ITEM_INT 消息格式,避免 QGC 的自动解析和优化,确保航点数据的一致性。同时需要在上传前完全清除旧任务,确保任务状态重置,避免新旧任务混合,并使用正确的坐标系转换和参数范围控制,避免触发 QGC 的自动优化机制。
2. 技术原理
2.1 MAVSDK-Python MissionItem 数据结构
MAVSDK-Python 的 MissionItem 是航点任务的核心数据结构,包含了无人机执行任务所需的所有信息:
核心字段
class MissionItem:
# 位置信息
latitude_deg: float # 纬度(度,范围:-90 到 +90)
longitude_deg: float # 经度(度,范围:-180 到 +180)
relative_altitude_m: float # 相对起飞高度(米)
# 飞行参数
speed_m_s: float # 飞行速度(米/秒)
is_fly_through: bool # 是否飞越航点(True=飞越,False=停留)
acceptance_radius_m: float # 航点接受半径(米)
yaw_deg: float # 偏航角(度)
# 云台控制
gimbal_pitch_deg: float # 云台俯仰角(度)
gimbal_yaw_deg: float # 云台偏航角(度)
# 相机控制
camera_action: CameraAction # 相机动作
camera_photo_interval_s: float # 拍照间隔(秒)
camera_photo_distance_m: float # 拍照距离(米)
# 飞行控制
vehicle_action: VehicleAction # 飞行器动作
loiter_time_s: float # 盘旋时间(秒)
枚举类型
class CameraAction(Enum):
NONE = "NONE" # 无动作
TAKE_PHOTO = "TAKE_PHOTO" # 拍照
START_VIDEO = "START_VIDEO" # 开始录像
STOP_VIDEO = "STOP_VIDEO" # 停止录像
START_PHOTO_INTERVAL = "START_PHOTO_INTERVAL" # 开始定时拍照
STOP_PHOTO_INTERVAL = "STOP_PHOTO_INTERVAL" # 停止定时拍照
START_PHOTO_DISTANCE = "START_PHOTO_DISTANCE" # 开始距离拍照
STOP_PHOTO_DISTANCE = "STOP_PHOTO_DISTANCE" # 停止距离拍照
class VehicleAction(Enum):
NONE = "NONE" # 无动作
TAKEOFF = "TAKEOFF" # 起飞
LAND = "LAND" # 降落
HOLD = "HOLD" # 悬停
RETURN_TO_LAUNCH = "RETURN_TO_LAUNCH" # 返航
TRANSITION_TO_FW = "TRANSITION_TO_FW" # 切换到固定翼模式
TRANSITION_TO_MC = "TRANSITION_TO_MC" # 切换到多旋翼模式
2.2 MissionRaw 与 Mission 接口的区别
MAVSDK-Python 提供了两个不同的任务接口,它们在数据格式和用途上有重要区别:
Mission 接口(高层接口)
# 使用 Mission 接口
from mavsdk.mission import MissionItem, MissionPlan
# 创建航点
mission_item = MissionItem(
latitude_deg=47.641627578463165,
longitude_deg=122.14016532897949,
relative_altitude_m=10.0,
speed_m_s=5.0,
is_fly_through=True,
# ... 其他参数
)
# 创建任务计划
mission_plan = MissionPlan([mission_item])
# 上传任务
await drone.mission.upload_mission(mission_plan)
特点:
- 使用高层数据结构
- 自动处理数据转换
- 适合简单的航点任务
- 与 QGC 兼容性较差
MissionRaw 接口(底层接口)
# 使用 MissionRaw 接口
from mavsdk.mission_raw import MissionItem as RawMissionItem
# 创建原始航点
raw_item = RawMissionItem(
seq=0, # 序列号
frame=6, # 坐标系(MAV_FRAME_GLOBAL_RELATIVE_ALT_INT)
command=16, # 命令(MAV_CMD_NAV_WAYPOINT)
current=1, # 当前航点标志
autocontinue=1, # 自动继续标志
param1=0.0, # 参数1(盘旋时间)
param2=5.0, # 参数2(接受半径)
param3=0.0, # 参数3(未使用)
param4=0.0, # 参数4(偏航角)
x=476416275, # 纬度(度*1e7)
y=1221401653, # 经度(度*1e7)
z=10.0, # 高度(米)
mission_type=0 # 任务类型
)
# 上传原始任务
await drone.mission_raw.upload_mission([raw_item])
特点:
- 直接使用 MAVLink 消息格式
- 完全控制数据格式
- 与 QGC 兼容性更好
- 需要手动处理数据转换
2.3 QGC 航点解析机制
QGroundControl 5.0.6 的航点解析机制存在一些已知问题:
解析流程
接收 MAVLink 消息
# QGC 接收 MISSION_ITEM_INT 消息 def parse_mission_item(message): seq = message.seq frame = message.frame command = message.command # ... 解析其他字段数据验证和转换
# QGC 的数据验证逻辑(存在问题) if command == MAV_CMD_NAV_WAYPOINT: # 错误:某些条件下会创建额外航点 if param1 > 0: # 问题条件 create_additional_waypoint()航点显示和存储
- 将解析的航点添加到任务列表
- 更新界面显示
- 存储到本地数据库
已知问题
自动航点生成
- QGC 5.0.6 在某些条件下会自动生成额外航点
- 这些航点没有正确的序列号
- 导致任务执行异常
参数解析错误
- 某些参数被错误解释
- 导致航点属性不正确
- 影响任务执行逻辑
坐标系转换问题
- 经纬度精度处理不当
- 坐标系转换算法错误
- 导致航点位置偏移
2.4 MAVLink 协议层面的兼容性
MISSION_ITEM_INT 消息格式
# MAVLink MISSION_ITEM_INT 消息结构
class MissionItemInt:
target_system: int # 目标系统ID
target_component: int # 目标组件ID
seq: int # 序列号
frame: int # 坐标系
command: int # 命令类型
current: int # 当前航点标志
autocontinue: int # 自动继续标志
param1: float # 参数1
param2: float # 参数2
param3: float # 参数3
param4: float # 参数4
x: int # 纬度(度*1e7)
y: int # 经度(度*1e7)
z: float # 高度(米)
mission_type: int # 任务类型
关键参数说明
坐标系 (frame)
MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6- 使用相对高度的全球坐标系
- 经纬度精度为 1e7
命令类型 (command)
MAV_CMD_NAV_WAYPOINT = 16:普通航点MAV_CMD_NAV_TAKEOFF = 22:起飞命令MAV_CMD_NAV_LAND = 21:降落命令
参数含义
param1:盘旋时间(秒)param2:接受半径(米)param3:未使用param4:偏航角(度)
兼容性要求
序列号连续性
- 航点序列号必须连续
- 从0开始递增
- 不能有重复或跳跃
参数范围
- 经纬度范围:-90 到 +90(纬度),-180 到 +180(经度)
- 高度范围:0 到 1000 米
- 速度范围:0.1 到 50 米/秒
命令兼容性
- 使用标准的 MAVLink 命令
- 避免使用非标准命令
- 确保命令参数正确
3. 解决方案架构
3.1 双层数据转换架构
本解决方案采用双层数据转换架构,将高层 MissionItem 数据转换为底层 MissionRaw 格式,确保与 QGC 5.0.6 的完美兼容:
架构组件
graph TD
A[User Data Input] --> B[Field Validation & Mapping]
B --> C[MissionItem Construction]
C --> D[Enum Type Conversion]
D --> E[MissionRaw Conversion]
E --> F[Flight Controller Upload]
F --> G[QGC Display]第一层:高层 MissionItem 数据组织
# 高层 MissionItem 数据构造
class MissionItemConstructor:
def __init__(self):
self.required_fields = [
'latitude_deg', 'longitude_deg', 'relative_altitude_m',
'speed_m_s', 'is_fly_through'
]
self.optional_fields = [
'gimbal_pitch_deg', 'gimbal_yaw_deg', 'camera_action',
'loiter_time_s', 'camera_photo_interval_s', 'acceptance_radius_m',
'yaw_deg', 'camera_photo_distance_m', 'vehicle_action'
]
def validate_and_construct(self, waypoint_data: dict) -> MissionItem:
"""验证并构造 MissionItem 对象"""
# 字段验证
self._validate_required_fields(waypoint_data)
# 数据类型转换
converted_data = self._convert_data_types(waypoint_data)
# 枚举类型转换
converted_data = self._convert_enums(converted_data)
# 构造 MissionItem
return MissionItem(**converted_data)
def validate_mission_data(self, mission_data: dict) -> List[MissionItem]:
"""验证并构造多个 MissionItem 对象"""
mission_items = []
for waypoint_data in mission_data['waypoints']:
mission_item = self.validate_and_construct(waypoint_data)
mission_items.append(mission_item)
return mission_items
第二层:底层 MissionRaw 转换器
# 底层 MissionRaw 转换器
class MissionRawConverter:
def __init__(self):
self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6
self.MAV_CMD_NAV_WAYPOINT = 16
self.MAV_CMD_NAV_TAKEOFF = 22
self.MAV_CMD_NAV_LAND = 21
def convert_items_to_raw(self, items: List[MissionItem]) -> List[RawMissionItem]:
"""将 MissionItem 列表转换为 RawMissionItem 列表"""
raw_items = []
seq_counter = 0
for item in items:
# 处理特殊命令(TAKEOFF/LAND)
if item.vehicle_action == VehicleAction.TAKEOFF:
raw_items.append(self._create_takeoff_item(item, seq_counter))
seq_counter += 1
elif item.vehicle_action == VehicleAction.LAND:
raw_items.append(self._create_land_item(item, seq_counter))
seq_counter += 1
continue # LAND 后不再添加 NAV_WAYPOINT
# 创建普通航点
raw_items.append(self._create_waypoint_item(item, seq_counter))
seq_counter += 1
return raw_items
3.2 数据流程设计
完整数据流程
数据输入阶段
# 用户代码数据输入示例(符合航点输出协议) mission_data = { "selected_drone_id": "drone_001", "waypoints": [ { "latitude_deg": 47.39804, "longitude_deg": 8.54557, "relative_altitude_m": 30.0, "speed_m_s": 5.0, "is_fly_through": True, "gimbal_pitch_deg": 0.0, "gimbal_yaw_deg": 0.0, "camera_action": "NONE", "loiter_time_s": 0.0, "camera_photo_interval_s": 0.0, "acceptance_radius_m": 5.0, "yaw_deg": 0.0, "camera_photo_distance_m": 0.0, "vehicle_action": "NONE" } ] }数据验证阶段
# 字段验证 def validate_mission_data(data: dict) -> dict: # 检查必填字段 if 'selected_drone_id' not in data: raise ValueError("Missing required field: selected_drone_id") if 'waypoints' not in data: raise ValueError("Missing required field: waypoints") # 验证航点数据 for i, waypoint in enumerate(data['waypoints']): required_fields = ['latitude_deg', 'longitude_deg', 'relative_altitude_m', 'speed_m_s'] for field in required_fields: if field not in waypoint: raise ValueError(f"Missing required field in waypoint {i}: {field}") # 数据类型验证 waypoint['latitude_deg'] = float(waypoint['latitude_deg']) waypoint['longitude_deg'] = float(waypoint['longitude_deg']) # ... 其他字段验证 return data数据转换阶段
# MissionItem 构造 mission_items = [] for waypoint_data in mission_data['waypoints']: mission_item = MissionItem( latitude_deg=waypoint_data['latitude_deg'], longitude_deg=waypoint_data['longitude_deg'], relative_altitude_m=waypoint_data['relative_altitude_m'], speed_m_s=waypoint_data['speed_m_s'], is_fly_through=waypoint_data.get('is_fly_through', True), gimbal_pitch_deg=waypoint_data.get('gimbal_pitch_deg', 0.0), gimbal_yaw_deg=waypoint_data.get('gimbal_yaw_deg', 0.0), camera_action=CameraAction(waypoint_data.get('camera_action', 'NONE')), loiter_time_s=waypoint_data.get('loiter_time_s', 0.0), camera_photo_interval_s=waypoint_data.get('camera_photo_interval_s', 0.0), acceptance_radius_m=waypoint_data.get('acceptance_radius_m', 5.0), yaw_deg=waypoint_data.get('yaw_deg', 0.0), camera_photo_distance_m=waypoint_data.get('camera_photo_distance_m', 0.0), vehicle_action=VehicleAction(waypoint_data.get('vehicle_action', 'NONE')) ) mission_items.append(mission_item)MissionRaw 转换阶段
# 转换为 MissionRaw 格式 raw_items = [] for i, mission_item in enumerate(mission_items): raw_item = RawMissionItem( seq=i, frame=6, # MAV_FRAME_GLOBAL_RELATIVE_ALT_INT command=16, # MAV_CMD_NAV_WAYPOINT current=1 if i == 0 else 0, autocontinue=1 if mission_item.is_fly_through else 0, param1=mission_item.loiter_time_s, # 盘旋时间 param2=mission_item.acceptance_radius_m, # 接受半径 param3=0.0, # 未使用 param4=mission_item.yaw_deg, # 偏航角 x=int(mission_item.latitude_deg * 1e7), # 纬度*1e7 y=int(mission_item.longitude_deg * 1e7), # 经度*1e7 z=mission_item.relative_altitude_m, # 高度 mission_type=0 ) raw_items.append(raw_item)任务上传阶段
# 上传到飞行控制器 await drone.mission_raw.clear_mission() # 清除旧任务 await drone.mission_raw.upload_mission(raw_items) # 上传新任务
3.3 兼容性保证机制
QGC 兼容性保证
使用 MissionRaw 接口
- 直接使用
MISSION_ITEM_INT消息格式 - 避免 QGC 的自动解析和优化
- 确保航点数据的一致性
- 直接使用
正确的任务清除
# 上传前清除旧任务 async def upload_mission_with_clear(self, items: List[MissionItem]): # 清除旧任务 await self.drone.mission_raw.clear_mission() # 等待清除完成 await asyncio.sleep(0.5) # 转换并上传新任务 raw_items = self._convert_items_to_raw(items) await self.drone.mission_raw.upload_mission(raw_items)参数范围控制
# 确保参数在 QGC 兼容范围内 def validate_parameters(self, item: MissionItem) -> MissionItem: # 经纬度范围检查 if not (-90 <= item.latitude_deg <= 90): raise ValueError("Latitude out of range") if not (-180 <= item.longitude_deg <= 180): raise ValueError("Longitude out of range") # 高度范围检查 if not (0 <= item.relative_altitude_m <= 1000): raise ValueError("Altitude out of range") # 速度范围检查 if not (0.1 <= item.speed_m_s <= 50): raise ValueError("Speed out of range") return item
4. 核心实现
4.1 MissionItem 数据构造
字段映射和验证
# 完整的字段映射和验证实现
class MissionItemConstructor:
def __init__(self):
# 必填字段定义
self.required_fields = {
'latitude_deg': {'type': float, 'range': (-90, 90)},
'longitude_deg': {'type': float, 'range': (-180, 180)},
'relative_altitude_m': {'type': float, 'range': (0, 1000)},
'speed_m_s': {'type': float, 'range': (0.1, 50)},
'is_fly_through': {'type': bool, 'range': None}
}
# 可选字段定义
self.optional_fields = {
'gimbal_pitch_deg': {'type': float, 'range': (-90, 90), 'default': 0.0},
'gimbal_yaw_deg': {'type': float, 'range': (-180, 180), 'default': 0.0},
'camera_action': {'type': str, 'range': None, 'default': 'NONE'},
'loiter_time_s': {'type': float, 'range': (0, 3600), 'default': 0.0},
'camera_photo_interval_s': {'type': float, 'range': (0, 3600), 'default': 0.0},
'acceptance_radius_m': {'type': float, 'range': (0.1, 100), 'default': 5.0},
'yaw_deg': {'type': float, 'range': (-180, 180), 'default': 0.0},
'camera_photo_distance_m': {'type': float, 'range': (0, 1000), 'default': 0.0},
'vehicle_action': {'type': str, 'range': None, 'default': 'NONE'}
}
def validate_and_construct(self, waypoint_data: dict) -> MissionItem:
"""验证并构造 MissionItem 对象"""
try:
# 验证必填字段
self._validate_required_fields(waypoint_data)
# 处理可选字段
processed_data = self._process_optional_fields(waypoint_data)
# 数据类型转换
converted_data = self._convert_data_types(processed_data)
# 枚举类型转换
converted_data = self._convert_enums(converted_data)
# 构造 MissionItem
return MissionItem(**converted_data)
except Exception as e:
logger.error(f"MissionItem construction failed: {e}")
raise ValueError(f"Invalid waypoint data: {e}")
def _validate_required_fields(self, data: dict):
"""验证必填字段"""
for field, config in self.required_fields.items():
if field not in data:
raise ValueError(f"Missing required field: {field}")
# 类型检查
if not isinstance(data[field], config['type']):
try:
data[field] = config['type'](data[field])
except (ValueError, TypeError):
raise ValueError(f"Invalid type for {field}: expected {config['type'].__name__}")
# 范围检查
if config['range'] is not None:
min_val, max_val = config['range']
if not (min_val <= data[field] <= max_val):
raise ValueError(f"{field} out of range: {data[field]} not in [{min_val}, {max_val}]")
def _process_optional_fields(self, data: dict) -> dict:
"""处理可选字段"""
processed = data.copy()
for field, config in self.optional_fields.items():
if field not in processed:
processed[field] = config['default']
else:
# 类型转换
if not isinstance(processed[field], config['type']):
try:
processed[field] = config['type'](processed[field])
except (ValueError, TypeError):
processed[field] = config['default']
# 范围检查
if config['range'] is not None:
min_val, max_val = config['range']
if not (min_val <= processed[field] <= max_val):
processed[field] = config['default']
return processed
枚举类型转换
# 枚举类型转换实现
class EnumConverter:
def __init__(self):
self.camera_action_map = {
'NONE': CameraAction.NONE,
'TAKE_PHOTO': CameraAction.TAKE_PHOTO,
'START_VIDEO': CameraAction.START_VIDEO,
'STOP_VIDEO': CameraAction.STOP_VIDEO,
'START_PHOTO_INTERVAL': CameraAction.START_PHOTO_INTERVAL,
'STOP_PHOTO_INTERVAL': CameraAction.STOP_PHOTO_INTERVAL,
'START_PHOTO_DISTANCE': CameraAction.START_PHOTO_DISTANCE,
'STOP_PHOTO_DISTANCE': CameraAction.STOP_PHOTO_DISTANCE
}
self.vehicle_action_map = {
'NONE': VehicleAction.NONE,
'TAKEOFF': VehicleAction.TAKEOFF,
'LAND': VehicleAction.LAND,
'HOLD': VehicleAction.HOLD,
'RETURN_TO_LAUNCH': VehicleAction.RETURN_TO_LAUNCH,
'TRANSITION_TO_FW': VehicleAction.TRANSITION_TO_FW,
'TRANSITION_TO_MC': VehicleAction.TRANSITION_TO_MC
}
def convert_camera_action(self, action_str: str) -> CameraAction:
"""转换相机动作枚举"""
action_upper = str(action_str).upper()
if action_upper in self.camera_action_map:
return self.camera_action_map[action_upper]
else:
logger.warning(f"Unknown camera action: {action_str}, using NONE")
return CameraAction.NONE
def convert_vehicle_action(self, action_str: str) -> VehicleAction:
"""转换飞行器动作枚举"""
action_upper = str(action_str).upper()
if action_upper in self.vehicle_action_map:
return self.vehicle_action_map[action_upper]
else:
logger.warning(f"Unknown vehicle action: {action_str}, using NONE")
return VehicleAction.NONE
数据完整性检查
# 数据完整性检查实现
class DataIntegrityChecker:
def __init__(self):
self.coordinate_precision = 1e7 # 经纬度精度
self.max_waypoint_distance = 1000 # 最大航点距离(米)
def check_waypoint_integrity(self, waypoints: List[MissionItem]) -> bool:
"""检查航点数据完整性"""
try:
# 检查航点数量
if len(waypoints) == 0:
raise ValueError("No waypoints provided")
if len(waypoints) > 100:
raise ValueError("Too many waypoints (max 100)")
# 检查航点间距
self._check_waypoint_distances(waypoints)
# 检查高度一致性
self._check_altitude_consistency(waypoints)
# 检查速度合理性
self._check_speed_consistency(waypoints)
return True
except Exception as e:
logger.error(f"Waypoint integrity check failed: {e}")
return False
def _check_waypoint_distances(self, waypoints: List[MissionItem]):
"""检查航点间距"""
for i in range(1, len(waypoints)):
prev_wp = waypoints[i-1]
curr_wp = waypoints[i]
distance = self._calculate_distance(
prev_wp.latitude_deg, prev_wp.longitude_deg,
curr_wp.latitude_deg, curr_wp.longitude_deg
)
if distance > self.max_waypoint_distance:
raise ValueError(f"Waypoint {i} too far from previous waypoint: {distance}m")
def _check_altitude_consistency(self, waypoints: List[MissionItem]):
"""检查高度一致性"""
altitudes = [wp.relative_altitude_m for wp in waypoints]
min_alt = min(altitudes)
max_alt = max(altitudes)
if max_alt - min_alt > 500: # 高度差超过500米
logger.warning("Large altitude variation in waypoints")
def _check_speed_consistency(self, waypoints: List[MissionItem]):
"""检查速度一致性"""
speeds = [wp.speed_m_s for wp in waypoints]
for speed in speeds:
if speed < 0.1 or speed > 50:
raise ValueError(f"Invalid speed: {speed}m/s")
4.2 _convert_items_to_raw() 转换器
MAVLink 命令映射
# 完整的 MissionRaw 转换器实现
class MissionRawConverter:
def __init__(self):
# MAVLink 常量定义
self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6
self.MAV_CMD_NAV_WAYPOINT = 16
self.MAV_CMD_NAV_TAKEOFF = 22
self.MAV_CMD_NAV_LAND = 21
self.MAV_CMD_NAV_RETURN_TO_LAUNCH = 20
# 坐标精度
self.COORDINATE_PRECISION = 1e7
def convert_items_to_raw(self, items: List[MissionItem]) -> List[RawMissionItem]:
"""将 MissionItem 列表转换为 RawMissionItem 列表"""
raw_items = []
seq_counter = 0
for i, item in enumerate(items):
try:
# 处理特殊命令
if item.vehicle_action == VehicleAction.TAKEOFF:
raw_items.append(self._create_takeoff_item(item, seq_counter))
seq_counter += 1
elif item.vehicle_action == VehicleAction.LAND:
raw_items.append(self._create_land_item(item, seq_counter))
seq_counter += 1
continue # LAND 后不再添加 NAV_WAYPOINT
elif item.vehicle_action == VehicleAction.RETURN_TO_LAUNCH:
raw_items.append(self._create_rtl_item(item, seq_counter))
seq_counter += 1
continue
# 创建普通航点
raw_items.append(self._create_waypoint_item(item, seq_counter))
seq_counter += 1
except Exception as e:
logger.error(f"Failed to convert waypoint {i}: {e}")
raise ValueError(f"Waypoint conversion failed: {e}")
return raw_items
def _create_waypoint_item(self, item: MissionItem, seq: int) -> RawMissionItem:
"""创建普通航点"""
return RawMissionItem(
seq=seq,
frame=self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
command=self.MAV_CMD_NAV_WAYPOINT,
current=1 if seq == 0 else 0,
autocontinue=1 if item.is_fly_through else 0,
param1=item.loiter_time_s,
param2=item.acceptance_radius_m,
param3=0.0, # 未使用
param4=item.yaw_deg,
x=int(item.latitude_deg * self.COORDINATE_PRECISION),
y=int(item.longitude_deg * self.COORDINATE_PRECISION),
z=item.relative_altitude_m,
mission_type=0
)
def _create_takeoff_item(self, item: MissionItem, seq: int) -> RawMissionItem:
"""创建起飞命令"""
return RawMissionItem(
seq=seq,
frame=self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
command=self.MAV_CMD_NAV_TAKEOFF,
current=1 if seq == 0 else 0,
autocontinue=1,
param1=0.0, # 最小俯仰角(多旋翼不使用)
param2=0.0,
param3=0.0,
param4=item.yaw_deg,
x=int(item.latitude_deg * self.COORDINATE_PRECISION),
y=int(item.longitude_deg * self.COORDINATE_PRECISION),
z=item.relative_altitude_m, # 起飞高度
mission_type=0
)
def _create_land_item(self, item: MissionItem, seq: int) -> RawMissionItem:
"""创建降落命令"""
return RawMissionItem(
seq=seq,
frame=self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
command=self.MAV_CMD_NAV_LAND,
current=1 if seq == 0 else 0,
autocontinue=0, # 降落不自动继续
param1=0.0,
param2=0.0,
param3=0.0,
param4=item.yaw_deg,
x=int(item.latitude_deg * self.COORDINATE_PRECISION),
y=int(item.longitude_deg * self.COORDINATE_PRECISION),
z=0.0, # 降落时高度为0
mission_type=0
)
def _create_rtl_item(self, item: MissionItem, seq: int) -> RawMissionItem:
"""创建返航命令"""
return RawMissionItem(
seq=seq,
frame=self.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
command=self.MAV_CMD_NAV_RETURN_TO_LAUNCH,
current=1 if seq == 0 else 0,
autocontinue=0,
param1=0.0,
param2=0.0,
param3=0.0,
param4=0.0,
x=0, # 返航不需要坐标
y=0,
z=0,
mission_type=0
)
坐标系转换
# 坐标系转换实现
class CoordinateConverter:
def __init__(self):
self.precision = 1e7 # 经纬度精度
def deg_to_int7(self, degrees: float) -> int:
"""将度数转换为 int32 格式(度*1e7)"""
return int(round(degrees * self.precision))
def int7_to_deg(self, int_value: int) -> float:
"""将 int32 格式转换为度数"""
return int_value / self.precision
def validate_coordinates(self, lat: float, lon: float) -> bool:
"""验证坐标有效性"""
return (-90 <= lat <= 90) and (-180 <= lon <= 180)
def calculate_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""计算两点间距离(米)"""
from math import radians, cos, sin, asin, sqrt
# 转换为弧度
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
# 使用 Haversine 公式
dlat = lat2 - lat1
dlon = lon2 - lon1
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
c = 2 * asin(sqrt(a))
# 地球半径(米)
r = 6371000
return c * r
4.3 upload_mission() 上传流程
任务清除机制
# 完整的任务上传实现
class MissionUploader:
def __init__(self, drone):
self.drone = drone
self.converter = MissionRawConverter()
self.clear_timeout = 5.0 # 清除超时时间
async def upload_mission(self, items: List[MissionItem]) -> str:
"""上传任务到飞行控制器"""
try:
# 检查连接状态
if not await self.drone.is_connected():
raise ValueError("Drone not connected")
# 清除旧任务
await self._clear_existing_mission()
# 转换航点
raw_items = self.converter.convert_items_to_raw(items)
# 上传新任务
await self.drone.mission_raw.upload_mission(raw_items)
logger.info(f"Mission uploaded successfully: {len(raw_items)} waypoints")
return "Mission uploaded successfully"
except Exception as e:
logger.error(f"Mission upload failed: {e}")
raise ValueError(f"Mission upload failed: {e}")
async def _clear_existing_mission(self):
"""清除现有任务"""
try:
# 清除任务
await self.drone.mission_raw.clear_mission()
# 等待清除完成
await asyncio.sleep(0.5)
# 验证清除结果
mission_items = await self.drone.mission_raw.download_mission()
if len(mission_items) > 0:
logger.warning("Mission clear may not be complete")
# 再次尝试清除
await self.drone.mission_raw.clear_mission()
await asyncio.sleep(0.5)
logger.info("Existing mission cleared")
except Exception as e:
logger.error(f"Failed to clear existing mission: {e}")
raise ValueError(f"Mission clear failed: {e}")
参考文档
在 QGC 和 大模型 FastAPI 后端实现的端到端无人机语音指令框架
1. 概述
1.1 版本信息
基础版本:QGroundControl v5.0.6 Stable 开发目标:为QGC添加语音交互能力,实现通过自然语言控制无人机
1.2 二次开发说明
本实现在不修改QGC核心功能的前提下,通过以下方式扩展语音交互能力:
新增源文件:
AudioRecorderController.h/cc:独立的音频录制控制器BackendSettings.h/cc:后端配置管理Backend.SettingsGroup.json:配置项定义BottomFlyViewToolBar.qml:添加语音按钮和交互逻辑
修改现有文件:
QGroundControlQmlGlobal.cc:注册新的QML类型CMakeLists.txt:添加新源文件到构建系统MainWindow.qml:添加全局状态管理
保持兼容性:
- 所有新功能作为可选模块,不影响现有功能
- 使用QGC现有的设置管理框架
- 遵循QGC的代码风格和架构设计
- 新增组件与原有组件解耦,便于独立维护
2. 系统架构
2.1 整体架构图
graph TB
subgraph QGC["QGroundControl 客户端"]
User["用户操作<br/>(按住/松开按钮)"]
subgraph UI["QML 界面层"]
ToolBar["BottomFlyViewToolBar.qml"]
end
subgraph Controller["C++ 控制器层"]
Audio["AudioRecorderController<br/>• startRecording()<br/>• stopRecording()<br/>• 音频格式配置<br/>• WAV文件生成"]
end
subgraph QtLayer["Qt Multimedia 层"]
QtAudio["QAudioSource / QAudioFormat<br/>• 音频设备管理<br/>• PCM数据采集"]
end
User -.-> ToolBar
ToolBar -->|调用C++接口| Audio
Audio -->|使用Qt框架| QtAudio
end
subgraph Backend["后端服务器 (FastAPI)"]
APIRouter["/api/v1/voice<br/>(FastAPI 路由)"]
VoiceCommand["POST /api/v1/voice/command<br/>voice_command()"]
Whisper["Whisper 模型<br/>(small, CPU, 单例)"]
Agent["DroneAgent<br/>(LLM 解释命令)"]
Fleet["FleetManager / MavSDKWrapper<br/>(实际控制无人机)"]
LLM["外部 / 本地部署 LLM 服务<br/>"]
APIRouter --> VoiceCommand
VoiceCommand --> Whisper
Whisper --> Agent
Agent --> LLM
Agent --> Fleet
end
Dialog["结果展示<br/>对话框"]
QtAudio -->|"HTTP POST<br/>(multipart/form-data)<br/>字段: audio(WAV)"| APIRouter
Fleet -->|"执行结果字符串<br/>(含ANSI颜色)"| Agent
Agent -->|"JSON { result: '执行结果...' }"| VoiceCommand
VoiceCommand --> Dialog
style QGC fill:#e1f5ff,stroke:#01579b,stroke-width:2px
style Backend fill:#fff3e0,stroke:#e65100,stroke-width:2px
style User fill:#f3e5f5,stroke:#4a148c
style ToolBar fill:#e8f5e9,stroke:#1b5e20
style Audio fill:#fff9c4,stroke:#f57f17
style QtAudio fill:#fce4ec,stroke:#880e4f
style Dialog fill:#f1f8e9,stroke:#33691e
style APIRouter fill:#ffe0b2,stroke:#ef6c00
style VoiceCommand fill:#ffe082,stroke:#ff8f00
style Whisper fill:#fff9c4,stroke:#fbc02d
style Agent fill:#e1bee7,stroke:#6a1b9a
style Fleet fill:#c8e6c9,stroke:#2e7d32
style LLM fill:#bbdefb,stroke:#1565c02.2 数据流向:时序图
完整交互流程
sequenceDiagram
autonumber
actor User as 用户
participant UI as QML界面<br/>BottomFlyViewToolBar
participant Controller as C++控制器<br/>AudioRecorderController
participant Timer as 定时器<br/>QTimer(50ms)
participant QtAudio as Qt Multimedia<br/>QAudioSource
participant Network as 网络层<br/>XMLHttpRequest
participant APIRouter as FastAPI路由<br/>/api/v1/voice
participant VoiceAPI as 语音接口<br/>voice_command()
participant Whisper as Whisper模型<br/>small@CPU(单例)
participant Agent as DroneAgent<br/>LLM 命令解释器
participant LLM as 外部 / 本地部署 LLM 服务<br/>
participant Fleet as 机队管理器<br/>FleetManager/MavSDK
%% 阶段1: 开始录音
rect rgb(227, 242, 253)
Note over User,QtAudio: 阶段1: 开始录音
User->>+UI: 按下"发送语音"按钮
UI->>+Controller: startRecording()
Controller->>Controller: 清空旧数据<br/>_rawAudioData.clear()
Controller->>QtAudio: 创建QAudioSource
activate QtAudio
QtAudio-->>Controller: 返回音频设备
Controller->>QtAudio: start() 启动录音
QtAudio-->>Controller: 返回QIODevice
Controller->>Timer: start() 启动定时器
activate Timer
Controller-->>UI: emit isRecordingChanged(true)
UI-->>User: 显示"录音中..."
Controller-->>UI: 录音已启动
end
%% 阶段2: 周期性读取音频数据
rect rgb(243, 229, 245)
Note over Timer,Controller: 阶段2: 周期性读取音频数据
loop 每50ms循环
Timer->>Controller: timeout() 触发
Controller->>QtAudio: bytesAvailable() 检查
QtAudio-->>Controller: 可用字节数
Controller->>QtAudio: read() 读取PCM数据
QtAudio-->>Controller: PCM音频数据
Controller->>Controller: _rawAudioData.append()<br/>追加到缓冲区
Note right of Controller: 内存缓冲区持续增长
end
end
%% 阶段3: 停止录音
rect rgb(255, 243, 224)
Note over User,Controller: 阶段3: 停止录音
User->>UI: 松开按钮
UI->>Controller: stopRecording()
Controller->>Timer: stop() 停止定时器
deactivate Timer
Controller->>QtAudio: 读取剩余数据
QtAudio-->>Controller: 最后的PCM数据
Controller->>QtAudio: stop() 停止录音
deactivate QtAudio
Controller->>Controller: _createWavHeader()<br/>生成WAV头部(44字节)
Controller->>Controller: _audioData = header + _rawAudioData<br/>拼接完整WAV文件
Note right of Controller: 完整WAV文件已准备就绪
Controller-->>UI: emit recordingFinished()
Controller-->>UI: emit isRecordingChanged(false)
UI-->>User: 显示确认对话框
deactivate UI
end
%% 阶段4: 用户确认
rect rgb(232, 245, 233)
Note over User,UI: 阶段4: 用户确认
User->>+UI: 点击"是"确认发送
end
%% 阶段5: 准备网络请求
rect rgb(255, 249, 196)
Note over UI,Network: 阶段5: 准备网络请求
UI->>Controller: 获取audioData
Controller-->>UI: 返回QByteArray(WAV文件)
UI->>UI: 转换QByteArray → Uint8Array
UI->>+Network: 构建multipart/form-data
Network->>Network: 生成boundary标识
Network->>Network: 构建HTTP头部
Network->>Network: 组装ArrayBuffer
Note right of Network: 完整HTTP请求已构建完成
UI->>Controller: clearAudioData() 立即清理
Note right of Controller: 内存清理(关键优化点)
end
%% 阶段6: 发送HTTP请求
rect rgb(252, 228, 236)
Note over Network,APIRouter: 阶段6: 发送HTTP请求
Network->>+APIRouter: POST /api/v1/voice/command<br/>Content-Type: multipart/form-data<br/>Body: audio=WAV
UI-->>User: 显示"等待响应..."
deactivate UI
end
%% 阶段7: 后端处理(语音 → 文本 → 命令 → 控制)
rect rgb(224, 242, 241)
Note over APIRouter,Fleet: 阶段7: 后端处理
APIRouter->>+VoiceAPI: 路由到 voice_command()<br/>依赖注入 DroneAgent
VoiceAPI->>VoiceAPI: 校验文件名/扩展名/内容长度
VoiceAPI->>VoiceAPI: 写入临时文件(temp.wav)
VoiceAPI->>+Whisper: get_whisper_model()<br/>单例加载 small 模型
Whisper-->>VoiceAPI: model 实例
VoiceAPI->>Whisper: transcribe(temp.wav)
Whisper-->>VoiceAPI: transcribed_text(自然语言命令)
VoiceAPI->>VoiceAPI: 删除临时文件
VoiceAPI->>+Agent: await process(transcribed_text)
Agent->>Agent: 构造控制提示词<br/>检查机队状态
Agent->>+Fleet: get_fleet_status()<br/>仅使用已连接无人机
Fleet-->>Agent: 当前无人机状态
Agent->>+LLM: chat.completions.create()<br/>生成控制代码
LLM-->>Agent: Python 控制代码
Agent->>Agent: 在受限作用域内执行代码<br/>调用 FleetManager/MavSDK
Agent-->>VoiceAPI: 执行结果字符串<br/>(含ANSI颜色/Success等)
VoiceAPI-->>-APIRouter: AgentResponse(result=...)
APIRouter-->>-Network: JSON响应<br/>{result: "..."}
end
%% 阶段8: 处理响应
rect rgb(225, 245, 254)
Note over Network,User: 阶段8: 处理响应
Network-->>+UI: onreadystatechange<br/>xhr.responseText
UI->>UI: parseResponseText()<br/>提取result字段
UI->>UI: ansiToHtml()<br/>转换ANSI颜色→HTML
UI->>Controller: clearAudioData() 防御性清理
deactivate Controller
Note right of Controller: 确保内存已释放
UI-->>User: 显示结果对话框<br/>可选择复制
deactivate UI
User->>User: 查看执行结果
end3. 核心组件
3.1 AudioRecorderController(C++音频控制器)
类设计
class AudioRecorderController : public QObject
{
Q_OBJECT
QML_ELEMENT
// QML可访问属性
Q_PROPERTY(bool isRecording READ isRecording NOTIFY isRecordingChanged)
Q_PROPERTY(QByteArray audioData READ audioData NOTIFY audioDataChanged)
Q_PROPERTY(int audioDataSize READ audioDataSize NOTIFY audioDataChanged)
public:
explicit AudioRecorderController(QObject *parent = nullptr);
~AudioRecorderController();
// 公共方法 - QML可调用
Q_INVOKABLE void startRecording();
Q_INVOKABLE void stopRecording();
Q_INVOKABLE void clearAudioData();
signals:
void isRecordingChanged(bool isRecording);
void audioDataChanged();
void recordingFinished();
void errorOccurred(const QString &errorString);
private:
QAudioSource* _audioSource; // 音频输入设备
QIODevice* _audioIODevice; // I/O设备接口
QTimer* _readTimer; // 定时读取音频数据
QAudioFormat _audioFormat; // 音频格式配置
QByteArray _rawAudioData; // 原始PCM数据
QByteArray _audioData; // 完整WAV数据(含头部)
bool _isRecording; // 录制状态
};
3.2 音频格式配置
AudioRecorderController::AudioRecorderController(QObject *parent)
: QObject(parent)
, _audioSource(nullptr)
, _audioIODevice(nullptr)
, _readTimer(new QTimer(this))
, _isRecording(false)
{
// 配置音频格式: 16位PCM, 44100Hz采样率, 单声道
_audioFormat.setSampleRate(44100);
_audioFormat.setChannelCount(1);
_audioFormat.setSampleFormat(QAudioFormat::SampleFormat::Int16);
// 设置定时器周期性读取音频数据(每50ms)
_readTimer->setInterval(50);
_readTimer->setSingleShot(false);
connect(_readTimer, &QTimer::timeout, this,
&AudioRecorderController::_onAudioDataReady);
}
3.3 开始录制流程
void AudioRecorderController::startRecording()
{
// 1. 状态检查
if (_isRecording) {
qCWarning(AudioRecorderControllerLog) << "Already recording";
return;
}
// 2. 清空旧数据
_rawAudioData.clear();
_audioData.clear();
// 3. 创建音频源
if (_audioSource) {
_audioSource->deleteLater();
}
_audioSource = new QAudioSource(_audioFormat, this);
// 4. 启动音频输入
_audioIODevice = _audioSource->start();
if (!_audioIODevice) {
emit errorOccurred("Failed to start audio input device");
return;
}
// 5. 启动定时器
_readTimer->start();
// 6. 更新状态
_isRecording = true;
emit isRecordingChanged(_isRecording);
}
3.4 周期性数据读取
void AudioRecorderController::_onAudioDataReady()
{
if (!_audioIODevice || !_isRecording) {
return;
}
// 检查可用字节数
qint64 bytesAvailable = _audioIODevice->bytesAvailable();
if (bytesAvailable > 0) {
// 读取音频数据并追加到缓冲区
QByteArray data = _audioIODevice->read(bytesAvailable);
if (!data.isEmpty()) {
_rawAudioData.append(data);
qCDebug(AudioRecorderControllerLog)
<< "Read audio data:" << data.size()
<< "bytes, total:" << _rawAudioData.size();
}
}
}
3.5 停止录制并生成WAV文件
void AudioRecorderController::stopRecording()
{
if (!_isRecording) {
return;
}
// 1. 停止定时器
_readTimer->stop();
// 2. 读取剩余数据
if (_audioIODevice && _audioIODevice->bytesAvailable() > 0) {
QByteArray remainingData = _audioIODevice->readAll();
if (!remainingData.isEmpty()) {
_rawAudioData.append(remainingData);
}
}
// 3. 停止音频源
if (_audioSource) {
_audioSource->stop();
}
_audioIODevice = nullptr;
// 4. 生成WAV文件头部
if (!_rawAudioData.isEmpty()) {
QByteArray wavHeader = _createWavHeader(_rawAudioData.size(),
_audioFormat);
_audioData = wavHeader + _rawAudioData;
// WAV文件结构验证
qCDebug(AudioRecorderControllerLog)
<< "WAV total size:" << _audioData.size();
}
// 5. 更新状态并发出信号
_isRecording = false;
emit isRecordingChanged(_isRecording);
emit recordingFinished();
emit audioDataChanged();
}
3.6 WAV文件头部生成
QByteArray AudioRecorderController::_createWavHeader(
qint32 dataSize,
const QAudioFormat &format) const
{
QByteArray header;
QDataStream stream(&header, QIODevice::WriteOnly);
stream.setByteOrder(QDataStream::LittleEndian);
// 计算格式参数
qint16 numChannels = format.channelCount(); // 1 (单声道)
qint32 sampleRate = format.sampleRate(); // 44100
qint16 bytesPerSample = format.bytesPerSample(); // 2 (16位)
qint16 bitsPerSample = bytesPerSample * 8; // 16
qint32 byteRate = sampleRate * numChannels * bytesPerSample;
qint16 blockAlign = numChannels * bytesPerSample;
// RIFF头部 (12字节)
stream.writeRawData("RIFF", 4);
qint32 fileSize = 36 + dataSize; // 总大小 - 8
stream << fileSize;
stream.writeRawData("WAVE", 4);
// fmt块 (24字节)
stream.writeRawData("fmt ", 4);
qint32 fmtChunkSize = 16; // PCM格式块大小
stream << fmtChunkSize;
qint16 audioFormat = 1; // PCM = 1
stream << audioFormat;
stream << numChannels;
stream << sampleRate;
stream << byteRate;
stream << blockAlign;
stream << bitsPerSample;
// data块头 (8字节)
stream.writeRawData("data", 4);
stream << dataSize;
// 验证头部大小 (应为44字节)
if (header.size() != 44) {
qCWarning(AudioRecorderControllerLog)
<< "WAV header size incorrect:" << header.size();
}
return header;
}
WAV文件格式详解:
偏移 大小 字段 值
─────────────────────────────────────
0 4 ChunkID "RIFF"
4 4 ChunkSize 文件大小-8
8 4 Format "WAVE"
12 4 Subchunk1ID "fmt "
16 4 Subchunk1Size 16 (PCM)
20 2 AudioFormat 1 (PCM)
22 2 NumChannels 1 (单声道)
24 4 SampleRate 44100
28 4 ByteRate 88200 (=44100*1*2)
32 2 BlockAlign 2 (=1*2)
34 2 BitsPerSample 16
36 4 Subchunk2ID "data"
40 4 Subchunk2Size PCM数据大小
44 N Data 实际音频数据
音频质量与性能平衡
| 参数 | 值 | 理由 |
|---|---|---|
| 采样率 | 44100 Hz | 标准CD音质,适合所有语音识别系统 |
| 位深度 | 16 bit | 足够的动态范围,比8bit清晰,比24bit节省空间 |
| 声道数 | 1 (单声道) | 语音识别不需要立体声,减少50%数据量 |
| 读取间隔 | 50 ms | 平衡响应性和CPU占用 |
数据量计算:
每秒数据量 = 44100 Hz × 2 bytes × 1 channel = 88,200 bytes/s ≈ 86 KB/s
10秒录音 ≈ 860 KB
30秒录音 ≈ 2.5 MB
音频参数配置
如需修改音频参数,编辑 AudioRecorderController 构造函数:
AudioRecorderController::AudioRecorderController(QObject *parent)
{
// 可自定义的参数
_audioFormat.setSampleRate(44100); // 采样率:8000, 16000, 44100, 48000
_audioFormat.setChannelCount(1); // 声道数:1=单声道, 2=立体声
_audioFormat.setSampleFormat( // 采样格式
QAudioFormat::SampleFormat::Int16 // Int16, Int32, Float
);
// 读取间隔(毫秒)
_readTimer->setInterval(50); // 10-100ms范围推荐
}
常见配置组合:
| 场景 | 采样率 | 位深度 | 声道 | 数据率 |
|---|---|---|---|---|
| 语音识别(推荐) | 44100Hz | 16bit | 单声道 | 86KB/s |
| 低质量/节省带宽 | 16000Hz | 16bit | 单声道 | 31KB/s |
| 高质量/专业录音 | 48000Hz | 24bit | 立体声 | 281KB/s |
| 电话音质 | 8000Hz | 16bit | 单声道 | 16KB/s |
3.8 QML界面层
主要组件结构
Item {
id: _root
// ========== 属性定义 ==========
property bool isBackendAlive: false
property bool isWaitingForBackendResponse: false
property bool isRecording: audioRecorderController.isRecording
// 后端API URLs
property string backendBaseUrl:
QGroundControl.settingsManager.backendSettings.backendBaseUrl.value
property string voiceApiPath:
QGroundControl.settingsManager.backendSettings.voiceApiPath.value
property string voiceApiUrl: _buildCompleteUrl(voiceApiPath)
// ========== 音频录制控制器 ==========
AudioRecorderController {
id: audioRecorderController
onRecordingFinished: {
// 录音完成,显示确认对话框
var dialog = voiceConfirmDialogComponent.createObject(mainWindow)
dialog.open()
}
onErrorOccurred: function(errorString) {
// 处理录音错误
console.error("Audio recording error:", errorString)
}
}
// ========== 录音按钮 ==========
QGCButton {
id: microphoneButton
text: isRecording ? qsTr("录音中...") : qsTr("发送语音")
enabled: !isWaitingForBackendResponse
onPressed: {
_root.startRecording()
}
onReleased: {
_root.stopRecording()
}
}
// ========== 确认对话框 ==========
Component {
id: voiceConfirmDialogComponent
QGCPopupDialog {
title: qsTr("发送语音指令")
buttons: Dialog.Yes | Dialog.No
onAccepted: {
var audioData = audioRecorderController.audioData
if (audioData && audioData.length > 0) {
_root.sendVoiceCommandFromMemory(audioData)
}
}
onRejected: {
audioRecorderController.clearAudioData()
}
}
}
// ========== 响应显示对话框 ==========
Component {
id: responseDialogComponent
QGCPopupDialog {
property string responseText: ""
property string plainText: ""
property string coloredHtml: ""
// 可滚动的文本显示区域
ScrollView {
TextEdit {
text: responseDialog.coloredHtml
textFormat: TextEdit.RichText
readOnly: true
selectByMouse: true
}
}
// 复制按钮
QGCButton {
text: qsTr("复制")
onClicked: {
clipboard.text = responseDialog.plainText
}
}
}
}
}
启动录制
function startRecording() {
try {
audioRecorderController.startRecording()
console.log("Started recording to memory")
} catch (e) {
console.error("Failed to start recording:", e)
}
}
停止录制
function stopRecording() {
try {
audioRecorderController.stopRecording()
console.log("Stopped recording")
} catch (e) {
console.error("Failed to stop recording:", e)
}
}
发送语音命令
function sendVoiceCommandFromMemory(audioData) {
if (!audioData || audioData.length === 0) {
console.error("No audio data to send")
audioRecorderController.clearAudioData()
return
}
// 设置等待状态
mainWindow.isWaitingForBackendResponse = true
try {
// ========== 数据转换 ==========
// 将QByteArray转换为Uint8Array
var uint8Array = new Uint8Array(audioData.length)
for (var i = 0; i < audioData.length; i++) {
uint8Array[i] = audioData.charCodeAt(i) & 0xFF
}
// ========== 构建multipart请求 ==========
var boundary = "----WebKitFormBoundary" + new Date().getTime()
var fileName = "recording_" + new Date().getTime() + ".wav"
// 辅助函数:字符串转字节数组
function stringToBytes(str) {
var bytes = new Uint8Array(str.length)
for (var i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i) & 0xFF
}
return bytes
}
// 构建multipart各部分
var boundaryStr = "--" + boundary + "\r\n"
var dispositionStr = 'Content-Disposition: form-data; ' +
'name="audio"; filename="' + fileName + '"\r\n'
var contentTypeStr = "Content-Type: audio/wav\r\n\r\n"
var crlfStr = "\r\n"
var closingStr = "--" + boundary + "--\r\n"
var boundaryBytes = stringToBytes(boundaryStr)
var dispositionBytes = stringToBytes(dispositionStr)
var contentTypeBytes = stringToBytes(contentTypeStr)
var crlfBytes = stringToBytes(crlfStr)
var closingBytes = stringToBytes(closingStr)
// ========== 组装完整请求体 ==========
var totalSize = boundaryBytes.length +
dispositionBytes.length +
contentTypeBytes.length +
uint8Array.length +
crlfBytes.length +
closingBytes.length
var multipartBuffer = new ArrayBuffer(totalSize)
var multipartView = new Uint8Array(multipartBuffer)
var offset = 0
// 按顺序复制各部分
multipartView.set(boundaryBytes, offset)
offset += boundaryBytes.length
multipartView.set(dispositionBytes, offset)
offset += dispositionBytes.length
multipartView.set(contentTypeBytes, offset)
offset += contentTypeBytes.length
multipartView.set(uint8Array, offset)
offset += uint8Array.length
multipartView.set(crlfBytes, offset)
offset += crlfBytes.length
multipartView.set(closingBytes, offset)
// ========== 发送HTTP请求 ==========
var xhr = new XMLHttpRequest()
xhr.open("POST", voiceApiUrl, true)
xhr.setRequestHeader("Content-Type",
"multipart/form-data; boundary=" + boundary)
xhr.setRequestHeader("Accept", "application/json")
// 立即清理音频数据
audioRecorderController.clearAudioData()
console.log("Sending audio, size:", totalSize)
// 发送请求
xhr.send(multipartBuffer)
// ========== 处理响应 ==========
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
var status = xhr.status
var body = xhr.responseText || ""
// 确保清理内存
audioRecorderController.clearAudioData()
mainWindow.isWaitingForBackendResponse = false
if (status >= 200 && status < 300) {
// 成功响应
var parsedBody = parseResponseText(body)
var displayText = parsedBody.length ?
parsedBody : qsTr("命令执行成功,但未返回内容")
var parsed = ansiToHtml(displayText)
var dialog = responseDialogComponent.createObject(
mainWindow, {
responseTitle: qsTr("命令执行结果"),
plainText: parsed.plain,
coloredHtml: parsed.colored
}
)
dialog.open()
} else {
// 错误响应
var errorText = qsTr("命令执行失败\n状态码: %1\n响应: %2")
.arg(status).arg(parseResponseText(body))
// 显示错误对话框...
}
}
}
xhr.onerror = function() {
audioRecorderController.clearAudioData()
mainWindow.isWaitingForBackendResponse = false
// 显示网络错误...
}
} catch (e) {
audioRecorderController.clearAudioData()
mainWindow.isWaitingForBackendResponse = false
console.error("Error sending voice command:", e)
}
}
音频质量与性能考虑
在发送语音命令时,音频数据的质量和性能参数直接影响传输效率和识别准确度。本实现采用以下参数配置:
音频参数配置:
| 参数 | 值 | 理由 |
|---|---|---|
| 采样率 | 44100 Hz | 标准CD音质,适合所有语音识别系统 |
| 位深度 | 16 bit | 足够的动态范围,比8bit清晰,比24bit节省空间 |
| 声道数 | 1 (单声道) | 语音识别不需要立体声,减少50%数据量 |
| 读取间隔 | 50 ms | 平衡响应性和CPU占用 |
数据量计算:
每秒数据量 = 44100 Hz × 2 bytes × 1 channel = 88,200 bytes/s ≈ 86 KB/s
10秒录音 ≈ 860 KB
30秒录音 ≈ 2.5 MB
为什么选择这些参数:
44100 Hz 采样率:这是标准CD音质,能够完整保留人声频率范围(20 Hz - 20 kHz),确保语音识别系统能够准确识别语音内容。虽然16000 Hz也能满足基本需求,但44100 Hz提供了更好的音质冗余,适应不同环境下的录音质量变化。
16 bit 位深度:提供了65536个量化级别,足够捕捉人声的动态范围。8 bit(256级)会导致明显的量化噪声,而24 bit虽然更精确,但会增加50%的数据量,对语音识别来说收益有限。
单声道:语音识别主要关注频率内容和时间序列,不需要立体声的空间信息。使用单声道可以将数据量减半,显著降低网络传输负担和内存占用。
50 ms 读取间隔:在录音过程中,每50毫秒读取一次音频数据,既能及时响应录音状态变化,又不会过度占用CPU资源。过短的间隔(如10 ms)会增加CPU开销,过长的间隔(如100 ms)会导致内存缓冲区过大。
常见配置组合对比:
| 场景 | 采样率 | 位深度 | 声道 | 数据率 | 适用性 |
|---|---|---|---|---|---|
| 语音识别(推荐) | 44100Hz | 16bit | 单声道 | 86KB/s | ✅ 最佳平衡 |
| 低质量/节省带宽 | 16000Hz | 16bit | 单声道 | 31KB/s | ⚠️ 音质可能不足 |
| 高质量/专业录音 | 48000Hz | 24bit | 立体声 | 281KB/s | ❌ 过度配置 |
| 电话音质 | 8000Hz | 16bit | 单声道 | 16KB/s | ❌ 音质较差 |
性能影响:
- 网络传输:10秒录音约860 KB,即使在较慢的网络环境下也能快速上传
- 内存占用:单次录音的内存占用可控,不会导致内存溢出
- CPU占用:50 ms读取间隔保证了流畅的录音体验,不会造成明显的CPU负担
如需修改音频参数,可在 AudioRecorderController 构造函数中调整:
AudioRecorderController::AudioRecorderController(QObject *parent)
{
// 可自定义的参数
_audioFormat.setSampleRate(44100); // 采样率:8000, 16000, 44100, 48000
_audioFormat.setChannelCount(1); // 声道数:1=单声道, 2=立体声
_audioFormat.setSampleFormat( // 采样格式
QAudioFormat::SampleFormat::Int16 // Int16, Int32, Float
);
// 读取间隔(毫秒)
_readTimer->setInterval(50); // 10-100ms范围推荐
}
multipart/form-data
与上面的 sendVoiceCommandFromMemory 函数对应,音频数据通过 multipart/form-data 编码发送到后端。
什么是 multipart/form-data
multipart/form-data 是 HTTP 协议中定义的一种内容编码类型(Content-Type),用于在单个 HTTP 请求中传输多个不同类型的数据块。
核心特点:
- 多部分传输:可在一个请求中同时发送文本字段和二进制文件
- 边界分隔:使用 boundary(边界标识符)分隔不同的数据部分
- 独立描述:每个部分都有自己的 Content-Disposition 和 Content-Type
- 二进制安全:能够正确传输任意二进制数据,不会损坏文件内容
为什么使用 multipart/form-data
文件上传的标准方式
传统的表单编码方式 application/x-www-form-urlencoded 存在局限:
- 只能传输文本数据
- 会对二进制数据进行 URL 编码,导致文件体积膨胀 33%
- 无法高效传输大文件
而 multipart/form-data 专为文件上传设计:
- 原样传输二进制数据,无额外开销
- 可同时上传多个文件
- 支持混合文本字段和文件
在本项目中的应用场景
QGC 需要将录制的 WAV 音频文件发送到后端服务器进行语音识别:
sequenceDiagram
participant Client as 客户端(QGC)
participant Server as 服务端(后端API)
Note over Client: 录制音频<br/>(内存中的WAV文件)
Client->>+Server: POST multipart/form-data
Note right of Client: 字段名: "audio"<br/>文件名: "recording.wav"<br/>内容类型: audio/wav<br/>二进制数据: [WAV bytes]
Note over Server: 语音识别 + 意图理解<br/>+ 命令执行
Server-->>-Client: JSON响应
Note left of Server: { "result": "执行结果" }选择 multipart/form-data 的原因:
- WAV 是二进制格式,必须保持原始字节不变
- 服务端可通过标准的文件上传处理库解析
- 符合 Web 标准,兼容性好
- 便于调试(可用 curl、Postman 等工具测试)
与其他格式的对比
| 编码格式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
application/x-www-form-urlencoded | 简单文本表单 | 简单,历史悠久 | 不支持文件,二进制数据效率低 |
application/json | API 数据交换 | 结构清晰,易解析 | 二进制需 Base64 编码(+33%体积) |
multipart/form-data | 文件上传 | 二进制高效,标准化 | 格式复杂,需手动构建 |
application/octet-stream | 纯二进制流 | 最简单 | 无元数据,无法传递文件名等信息 |
格式结构说明
基本组成:
请求头:
Content-Type: multipart/form-data; boundary=<边界标识符>
请求体:
--<边界标识符> ← 开始边界
Content-Disposition: form-data; ... ← 字段描述
Content-Type: ... ← 内容类型
← 空行(重要!)
[数据内容] ← 实际数据
--<边界标识符>-- ← 结束边界(多了两个横杠)
关键概念:
- Boundary(边界):唯一标识符,不能出现在数据内容中
- CRLF(\r\n):每行必须以
\r\n结尾(HTTP 标准) - Content-Disposition:描述字段名(name)和文件名(filename)
- Content-Type:指定数据的 MIME 类型
完整请求示例
POST /api/v1/voice/command HTTP/1.1
Host: 127.0.0.1:8000
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1234567890
Content-Length: 44132
Accept: application/json
------WebKitFormBoundary1234567890
Content-Disposition: form-data; name="audio"; filename="recording_1234567890.wav"
Content-Type: audio/wav
RIFF....WAVE....data[二进制音频数据]
------WebKitFormBoundary1234567890--
构建步骤
// 1. 生成唯一boundary
var boundary = "----WebKitFormBoundary" + new Date().getTime()
// 2. 构建各个部分(所有部分都是Uint8Array)
var parts = {
boundary: "--" + boundary + "\r\n",
disposition: 'Content-Disposition: form-data; name="audio"; filename="file.wav"\r\n',
contentType: "Content-Type: audio/wav\r\n\r\n",
audioData: [WAV文件的二进制数据],
crlf: "\r\n",
closing: "--" + boundary + "--\r\n"
}
// 3. 计算总大小
var totalSize = parts.boundary.length +
parts.disposition.length +
parts.contentType.length +
parts.audioData.length +
parts.crlf.length +
parts.closing.length
// 4. 分配ArrayBuffer
var buffer = new ArrayBuffer(totalSize)
var view = new Uint8Array(buffer)
// 5. 按顺序复制
var offset = 0
view.set(parts.boundary, offset); offset += parts.boundary.length
view.set(parts.disposition, offset); offset += parts.disposition.length
view.set(parts.contentType, offset); offset += parts.contentType.length
view.set(parts.audioData, offset); offset += parts.audioData.length
view.set(parts.crlf, offset); offset += parts.crlf.length
view.set(parts.closing, offset)
// 6. 发送
xhr.send(buffer)
4. 端到端语音链路
4.1 后端整体角色与接口约束
- 后端框架:基于一个现代的 Python Web 框架构建,对外提供 REST 风格 API。
- 版本管理:所有接口统一挂载在一个版本前缀之下,例如
/api/v1,便于未来扩展与兼容。 - 语音相关接口:
- 语音转命令接口:
POST /api/v1/voice/command - 语音转文字接口(测试语音):
POST /api/v1/voice/transcribe
- 语音转命令接口:
- 与 QGC 对接方式:
- QGC 前端通过
multipart/form-data上传字段名为audio的音频文件。 - 后端返回 JSON 结构,主体字段为
result或text等,用于承载执行结果或转写文字。
- QGC 前端通过
4.2 后端分层结构
API 接口层:
- 暴露
/voice/command和/voice/transcribe等 HTTP 接口。 - 负责请求解析、基础校验、依赖注入以及异常到 HTTP 错误码的转换。
- 暴露
语音处理层:
- 接收上传音频,完成格式和内容的基础检查(例如:文件是否为空、扩展名是否在允许列表中)。
- 将音频内容写入临时介质或缓冲区,以便后续语音识别模型使用。
- 调用本地语音识别模型(如 Whisper small@CPU,单例加载),得到转写文本。
智能体与无人机控制层:
- 提供一个“无人机智能体”对象,对外暴露
process(command_text, task_type)等高层方法。 - 内部持有一个“机队管理器”对象,用于实际下发飞行指令(如起飞、降落、绕圈、飞往航点等)。
- 通过外部大模型(LLM)完成自然语言到控制代码的转换,并在受控环境中执行。
- 提供一个“无人机智能体”对象,对外暴露
配置与异常处理层:
- 定义一组专门的异常类型(例如:音频为空、音频格式不支持、语音模型不可用、转写结果为空、智能体未初始化等),并由统一异常处理器转换为结构化 HTTP 响应。
4.3 语音转命令接口:逻辑流程
语音转命令接口的逻辑可以概括为以下步骤:
接收上传音频
- 使用一个通用的文件上传参数(例如
audio_file),通过multipart/form-data方式接收。 - 若文件名缺失或内容为空,抛出如
EmptyAudioError之类的业务异常。
- 使用一个通用的文件上传参数(例如
文件格式与内容校验
- 允许扩展名集合如:
{".wav", ".mp3", ".flac", ".m4a", ".ogg", ".webm", ".mpeg", ".mp4"}。 - 若扩展名不在允许列表中,抛出如
UnsupportedAudioFormatError异常,并在错误信息中提示支持的格式。 - QGC 端推荐统一上传标准 WAV(PCM, 44100Hz, 16bit, 单声道),以简化链路和调试。
- 允许扩展名集合如:
写入临时介质并调用语音模型
- 将上传的二进制内容写入一个临时路径或缓冲区,供语音识别模型使用。
- 通过类似
get_speech_model()的单例函数获取语音识别模型实例:- 首次调用时完成模型加载,并捕获缺少依赖、加载失败等情况。
- 后续请求复用同一个模型实例,避免重复加载导致的性能问题。
- 调用模型的
transcribe(temp_audio)方法获得transcribed_text。 - 无论成功与否,都在合适时机删除临时音频文件或释放缓冲区,避免磁盘/内存泄漏。
- 若
transcribed_text为空,抛出如AudioTranscribeError的语义化异常。
调用智能体进行命令解释与执行
- 通过依赖注入或全局管理函数获取当前的“无人机智能体”实例(例如
get_drone_agent())。 - 若智能体尚未正确初始化(例如机队管理器不可用等),直接返回指引用户检查环境的配置提示,而不是继续执行。
- 调用智能体的异步方法,例如:
agent.process(transcribed_text, task_type="control")
- 在
task_type="control"场景下,智能体内部的大致逻辑为:- 读取当前机队状态,只允许对“已连接无人机”进行操作。
- 若无可用无人机,直接返回例如“未连接任何无人机”的错误文案,而不是尝试自行连接。
- 构造上游约束清晰的提示词,将用户自然语言命令、可用方法列表和安全限制一同输入 LLM。
- 从 LLM 返回中提取 Python 控制代码片段,在受限制的命名空间内执行,仅暴露受控的机队管理对象和必要的工具方法。
- 汇总“生成代码文本 + 实际执行结果”,拼接成一个富文本字符串,作为最终返回值。
- 通过依赖注入或全局管理函数获取当前的“无人机智能体”实例(例如
统一结果封装与返回
- 将智能体返回的字符串包装到统一的响应模型中,例如:
{ "result": "<带或不带 ANSI 颜色的执行结果文本>" }
- 发生异常时,将内部异常转换为结构化 JSON 错误响应,包含至少:
- 机器可读的错误类型(如
"error_type": "empty_audio") - 面向用户的错误提示(如
"message": "音频文件为空,请重新录制后再试")
- 机器可读的错误类型(如
- 将智能体返回的字符串包装到统一的响应模型中,例如:
4.4 语音转文字接口(测试接口)
除语音转命令外,后端还提供一个纯“语音转文字”能力,主要用于:
- 调试语音识别链路(确认录音质量与模型表现)。
- 为其他上层应用提供字幕/转写服务。
该接口的典型行为:
- 与
/voice/command相同方式接收multipart/form-data的音频文件。 - 复用相同的语音识别模型,将音频转写为文本。
- 返回结构化响应,例如:
text: 转写得到的文本内容。language: 语言标识(如"zh")。duration: 音频时长的字符串表示。processing_time: 服务端处理耗时的字符串表示。
4.5 端到端时序总结(后端视角)
结合前端章节中的整体时序图,从后端视角可以将关键步骤概括为:
- 接收请求:收到
POST /api/v1/voice/command请求,Content-Type 为multipart/form-data,字段名为audio。 - 基础校验:检查文件名、扩展名、内容长度,过滤掉明显无效请求。
- 语音转写:将音频内容交给本地语音识别模型,得到自然语言
transcribed_text。 - 智能体处理:调用无人机智能体的
process()方法,由 LLM 生成受约束的控制代码并执行,严格遵守“只操作已连接无人机、不自行连接”的规则。 - 结果封装:将执行结果封装成统一 JSON 响应返回前端,可包含 ANSI 颜色信息以便前端渲染。
- 资源清理:确保临时音频文件和中间缓冲在成功或异常场景下均被正确清理,避免长期资源泄漏。
5. API 接口规范
5.1 语音命令接口
请求规范
POST /api/v1/voice/command
Content-Type: multipart/form-data; boundary=<boundary>
Accept: application/json
请求体(multipart格式):
------WebKitFormBoundary1234567890
Content-Disposition: form-data; name="audio"; filename="recording_1234567890.wav"
Content-Type: audio/wav
[WAV文件二进制数据]
------WebKitFormBoundary1234567890--
WAV文件格式要求:
- 格式:PCM
- 采样率:44100 Hz
- 位深度:16 bit
- 声道数:1 (单声道)
- 文件格式:标准WAV (RIFF header)
响应规范
成功响应(200 OK):
{
"result": "命令执行成功的详细信息"
}
result字段可以包含:
- 纯文本
- 带ANSI颜色代码的文本
- 多行文本(\n换行)
ANSI颜色代码示例:
\033[32m成功\033[0m: 起飞命令已执行
\033[33m警告\033[0m: 电池电量较低
\033[31m错误\033[0m: GPS信号弱
6. 附录:文件位置参考
C++文件
src/QmlControls/AudioRecorderController.h - 音频控制器头文件
src/QmlControls/AudioRecorderController.cc - 音频控制器实现
src/Settings/BackendSettings.h - 后端设置头文件
src/Settings/BackendSettings.cc - 后端设置实现
src/QmlControls/QGroundControlQmlGlobal.cc - QML类型注册
JSON配置文件
src/Settings/Backend.SettingsGroup.json - 后端设置配置
QML文件
src/QmlControls/BottomFlyViewToolBar.qml - 底部工具栏(含语音交互)
src/UI/MainWindow.qml - 主窗口状态管理
构建配置
src/QmlControls/CMakeLists.txt - 添加AudioRecorderController
参考文档
QGroundControl Docker 进阶构建指南
版本约束: 本文档基于 QGroundControl 5.0.6 版本编写
1. Android 构建
# 在项目根目录执行
./deploy/docker/run-docker-android.sh
依赖版本
| 组件 | 版本 |
|---|---|
| 基础镜像 | Ubuntu 22.04 |
| Java | OpenJDK 17 |
| Qt | 6.6.3 |
| Android SDK | 34 |
| Build Tools | 34.0.0 |
| NDK | 25.1.8937393 (25B) |
| Platform | android-28 (Android 9) |
| 构建工具 | CMake + Ninja |
| 时区 | Asia/Shanghai |
Qt 版本详情:
为什么 Android 使用 Qt 6.6.3?
在官方文档中,对QGC v5.0.6 的 Qt 版本要求明确指定为6.8.3,但因为NDK和Herelink兼容性问题,只能选择6.6.3进行编译。
- NDK 兼容性 - Qt 6.6.3 与 Android NDK 25.1.8937393 有最佳兼容性
- Herelink 支持 - 该版本对 Herelink 设备有完整的支持
- 构建工具链 - 与 CMake 3.24+ 和 Android SDK 34 配合良好
安装的 Qt 模块:
- qtcharts, qtpositioning, qtspeech, qt5compat
- qtmultimedia, qtserialport, qtimageformats
- qtshadertools, qtconnectivity, qtquick3d
- qtsensors, qtlocation
1.1 支持版本
架构支持: 构建同时支持两种架构:
armeabi-v7a(32位 ARM)arm64-v8a(64位 ARM)
Android 版本支持:
| 项目 | 版本 | 说明 |
|---|---|---|
| 最低版本 (minSdk) | API 28 (Android 9.0) | 设备必须运行 Android 9.0 或更高版本 |
| 目标版本 (targetSdk) | API 35 (Android 15) | 针对 Android 15 优化 |
| 编译版本 (compileSdk) | API 34 (Android 14) | 使用 Android 14 SDK 编译 |
- ✅ Android 9.0 (Pie, API 28)
- ✅ Android 10 (Q, API 29)
- ✅ Android 11 (R, API 30)
- ✅ Android 12 (S, API 31)
- ✅ Android 12L (Sv2, API 32)
- ✅ Android 13 (T, API 33)
- ✅ Android 14 (U, API 34)
- ✅ Android 15 (V, API 35)
1.2 构建说明
构建输出:
build/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release.apk
安装APK到设备:
如果使用wifi连接adb设备
#在usb连接时
adb tcpip 5555
#之后使用
adb connect {IP}:5555
# 使用adb安装APK到连接的Android设备
adb install build/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release-signed.apk
# 如果设备上已存在旧版本,使用以下命令强制覆盖安装
adb install -r build/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release-signed.apk
# 查看连接的设备
adb devices
# 卸载旧版本(如果需要)
adb uninstall org.mavlink.qgroundcontrol
1.3 APK 签名
为什么需要签名?
系统要求 - Android 系统强制要求所有 APK 必须签名才能安装,未签名的 APK 无法运行
应用身份 - 签名是应用的唯一标识,用于:
- 验证应用来源和开发者身份
- 防止应用被篡改或伪造
- 建立应用之间的信任关系
应用更新 - 只有使用相同密钥签名的新版本才能覆盖安装旧版本
- 更换密钥后,用户必须先卸载旧版本(会丢失数据)
- 无法在应用市场(如 Google Play)更新应用
安全保障 - 签名机制确保 APK 在分发过程中未被修改
生成密钥库:
使用以下脚本自动生成 Android 发布密钥库:
#!/usr/bin/env bash
# Script to create Android release keystore
# Make sure to install JDK first: sudo apt install openjdk-17-jdk-headless -y
set -e
echo "============================================"
echo "Android Release Keystore Generator"
echo "============================================"
echo ""
# Configuration - Edit these values
KEYSTORE_NAME="android_release_new.keystore"
KEY_ALIAS="qgc_release"
KEY_PASSWORD="{yourpasswd}" # Change this!
STORE_PASSWORD="{yourpasswd}" # Change this!
VALIDITY_DAYS=10000 # About 27 years
# Distinguished Name (DN) information
DN_CN="QGroundControl" # Common Name
DN_OU="Development" # Organizational Unit
DN_O="QGroundControl" # Organization
DN_L="City" # Locality/City
DN_S="State" # State
DN_C="US" # Country Code (2 letters)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KEYSTORE_PATH="$SCRIPT_DIR/$KEYSTORE_NAME"
echo "Keystore will be created at: $KEYSTORE_PATH"
echo "Key alias: $KEY_ALIAS"
echo ""
# Check if keytool is available
if ! command -v keytool &> /dev/null; then
echo "ERROR: keytool not found!"
echo "Please install JDK first:"
echo " sudo apt install openjdk-17-jdk-headless -y"
exit 1
fi
# Check if keystore already exists
if [ -f "$KEYSTORE_PATH" ]; then
read -p "Keystore already exists. Overwrite? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Cancelled."
exit 0
fi
rm -f "$KEYSTORE_PATH"
fi
# Generate keystore
echo "Generating keystore..."
keytool -genkey -v \
-keystore "$KEYSTORE_PATH" \
-alias "$KEY_ALIAS" \
-keyalg RSA \
-keysize 2048 \
-validity $VALIDITY_DAYS \
-storepass "$STORE_PASSWORD" \
-keypass "$KEY_PASSWORD" \
-dname "CN=$DN_CN, OU=$DN_OU, O=$DN_O, L=$DN_L, S=$DN_S, C=$DN_C"
echo ""
echo "============================================"
echo "Keystore created successfully!"
echo "============================================"
echo ""
echo "Add these lines to your deploy/docker/run-docker-android.sh:"
echo ""
echo "QT_ANDROID_KEYSTORE_PATH=\"/project/source/deploy/android/$KEYSTORE_NAME\""
echo "QT_ANDROID_KEYSTORE_ALIAS=\"$KEY_ALIAS\""
echo "QT_ANDROID_KEYSTORE_STORE_PASS=\"$STORE_PASSWORD\""
echo "QT_ANDROID_KEYSTORE_KEY_PASS=\"$KEY_PASSWORD\""
echo ""
echo "⚠️ IMPORTANT: Keep these passwords safe!"
echo "⚠️ Backup your keystore file - you cannot recover it if lost!"
echo ""
# Verify the keystore
echo "Verifying keystore..."
keytool -list -v -keystore "$KEYSTORE_PATH" -storepass "$STORE_PASSWORD" | head -20
使用步骤:
安装 JDK(如果未安装):
sudo apt install openjdk-17-jdk-headless -y修改脚本配置:
- 编辑
KEY_PASSWORD和STORE_PASSWORD为您的密码 - 根据需要修改 DN 信息(组织名称、城市等)
- 编辑
运行脚本:
chmod +x create_keystore.sh ./create_keystore.sh配置构建脚本: 将生成的配置信息添加到
run-docker-android.sh中
密钥配置:
密钥文件:deploy/android/android_release.keystore
配置位置:deploy/docker/run-docker-android.sh
QT_ANDROID_KEYSTORE_PATH="/project/source/deploy/android/android_release.keystore"
QT_ANDROID_KEYSTORE_ALIAS="qgc_release"
QT_ANDROID_KEYSTORE_STORE_PASS="{yourpasswd}"
QT_ANDROID_KEYSTORE_KEY_PASS="{yourpasswd}"
密钥管理建议:
- ⚠️ 不要丢失密钥文件和密码
- ⚠️ 更换密钥将导致应用无法覆盖升级
- ⚠️ 生产环境应使用独立的发布密钥
- ⚠️ 定期备份密钥库文件
1.4 故障排查
清理构建缓存:
sudo rm -rf build/shadow_build_dir
查看构建日志:
docker ps # 找到容器 ID
docker logs -f <container_id>
重新构建 Docker 镜像:
docker rmi qgc-android-docker
./deploy/docker/run-docker-android.sh
2. Ubuntu 构建
2.1 依赖版本
| 组件 | 版本 |
|---|---|
| 基础镜像 | Ubuntu 22.04 |
| Qt | 6.8.3 |
| 构建工具 | CMake + Ninja |
| 时区 | Asia/Shanghai |
安装的 Qt 模块:
- qtcharts, qtlocation, qtpositioning, qtspeech, qt5compat
- qtmultimedia, qtserialport, qtimageformats
- qtshadertools, qtconnectivity, qtquick3d, qtsensors
2.2 快速开始
./deploy/docker/run-docker-ubuntu.sh
构建输出:
- 路径:
build/AppDir/usr/bin/ - 可执行文件:
QGroundControl
运行:
./build/AppDir/usr/bin/QGroundControl
验证编译结果
在每次构建完成后,请务必验证以下信息:
1. 检查版本号
构建完成后,在QGroundControl中查看版本信息:
- Android版本: 设置 → 关于 → 版本信息
- Ubuntu版本: 帮助 → 关于QGroundControl
预期版本信息:
- 主版本:5.0.6
- 构建日期:应与您的构建时间一致
- Git提交:应与您使用的源码版本一致
2. 检查构建时间戳
# Android APK
ls -la build/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release-signed.apk
# Ubuntu可执行文件
ls -la build/AppDir/usr/bin/QGroundControl
3. 功能验证清单
- 应用正常启动
- 连接设备功能正常
- 航点规划功能可用
- 设置界面正常显示
- 版本信息正确显示
如果版本号或构建时间不正确,说明更新内容未实际编译,请重新执行构建流程。
3. 为什么使用 Docker?
- 环境一致性 - 所有依赖预装在镜像中
- 简化配置 - 无需手动安装 Qt、SDK、NDK
- 隔离构建 - 不污染主机环境
- 可重复性 - 任何机器都能获得相同的构建结果
3.1 网络优化
使用 --network=host 继承主机 DNS 配置,提高下载速度。
3.2 构建参数
CMake 配置参数:
CMAKE_BUILD_TYPE=Release- 发布版本QT_ANDROID_BUILD_ALL_ABIS=OFF- 仅构建指定架构QT_ANDROID_ABIS="armeabi-v7a;arm64-v8a"- 32位和64位ARMQT_ANDROID_SIGN_APK=ON- 启用APK签名ANDROID_PLATFORM=android-28- 最低支持 Android 9
4. 修改版构建脚本
4.1 Android 构建脚本
以下是修改版的 Android 构建脚本 run-docker-android.sh:
#!/usr/bin/env bash
# Exit immediately if a command exits with a non-zero status
set -e
# ============================================================
# Android APK Signing Configuration
# ============================================================
QT_ANDROID_KEYSTORE_PATH="/project/source/deploy/android/android_release.keystore"
QT_ANDROID_KEYSTORE_ALIAS="qgc_release"
QT_ANDROID_KEYSTORE_STORE_PASS="{yourpasswd}"
QT_ANDROID_KEYSTORE_KEY_PASS="{yourpasswd}"
# ============================================================
# Define variables for better maintainability
DOCKERFILE_PATH="./deploy/docker/Dockerfile-build-android"
IMAGE_NAME="qgc-android-docker"
SOURCE_DIR="$(pwd)"
BUILD_DIR="${SOURCE_DIR}/build"
# Default values
QGC_ENABLE_HERELINK=OFF
QT_ANDROID_SIGN_APK=ON
REBUILD_IMAGE=false
FULL_CLEAN=false
# Interactive mode for cleanup selection
echo "============================================"
echo "QGroundControl Android 构建选项"
echo "============================================"
echo ""
echo "请选择构建模式:"
echo "1) 增量编译 (最快,保留所有缓存) - 日常开发推荐"
echo "2) 完全清理 (删除构建目录和Docker镜像) - 30-60分钟"
echo "3) 重新构建Docker镜像"
echo "4) 退出"
echo ""
while true; do
read -p "请输入选择 (1-4): " choice
case $choice in
1)
echo "选择: 增量编译"
break
;;
2)
echo "选择: 完全清理"
FULL_CLEAN=true
REBUILD_IMAGE=true
break
;;
3)
echo "选择: 重新构建Docker镜像"
REBUILD_IMAGE=true
break
;;
4)
echo "退出脚本"
exit 0
;;
*)
echo "无效选择,请输入 1-4"
;;
esac
done
echo ""
# Ask about Herelink support
echo "============================================"
echo "Herelink 支持配置"
echo "============================================"
echo ""
while true; do
read -p "是否启用 Herelink 支持? (y/n): " herelink_choice
case $herelink_choice in
[Yy]*)
QGC_ENABLE_HERELINK=ON
echo "已启用 Herelink 支持"
break
;;
[Nn]*)
QGC_ENABLE_HERELINK=OFF
echo "未启用 Herelink 支持"
break
;;
*)
echo "请输入 y 或 n"
;;
esac
done
echo ""
# Handle full clean: delete everything
if [ "$FULL_CLEAN" = true ]; then
echo "============================================"
echo "执行完全清理..."
echo "============================================"
# Delete build directory
if [ -d "${BUILD_DIR}" ]; then
echo "清理构建目录: ${BUILD_DIR}"
sudo rm -rf "${BUILD_DIR}" 2>/dev/null || true
echo "✓ 构建目录已清理"
fi
# Delete Docker image
if docker image inspect "${IMAGE_NAME}" > /dev/null 2>&1; then
echo "删除Docker镜像: ${IMAGE_NAME}"
docker rmi "${IMAGE_NAME}" 2>/dev/null || true
echo "✓ Docker镜像已删除"
fi
echo "完全清理完成!"
echo ""
fi
# Create build directory if it doesn't exist
mkdir -p "${BUILD_DIR}"
# Check if Docker image exists, build only if needed
if ! docker image inspect "${IMAGE_NAME}" > /dev/null 2>&1 || [ "$REBUILD_IMAGE" = true ]; then
if [ "$REBUILD_IMAGE" = true ]; then
echo "============================================"
echo "重新构建Docker镜像..."
echo "============================================"
else
echo "============================================"
echo "构建Docker镜像..."
echo "============================================"
fi
docker build --file "${DOCKERFILE_PATH}" \
--build-arg QGC_ENABLE_HERELINK=$QGC_ENABLE_HERELINK \
--network=host \
-t "${IMAGE_NAME}" "${SOURCE_DIR}"
echo "✓ Docker镜像构建完成"
echo ""
fi
# Run the Docker container with adjusted mount points and DNS configuration
echo "============================================"
echo "启动Docker容器进行构建..."
echo "============================================"
docker run --rm \
--network=host \
-v "${SOURCE_DIR}:/project/source" \
-v "${BUILD_DIR}:/workspace/build" \
-e QT_ANDROID_SIGN_APK=$QT_ANDROID_SIGN_APK \
-e QT_ANDROID_KEYSTORE_PATH=$QT_ANDROID_KEYSTORE_PATH \
-e QT_ANDROID_KEYSTORE_ALIAS=$QT_ANDROID_KEYSTORE_ALIAS \
-e QT_ANDROID_KEYSTORE_STORE_PASS=$QT_ANDROID_KEYSTORE_STORE_PASS \
-e QT_ANDROID_KEYSTORE_KEY_PASS=$QT_ANDROID_KEYSTORE_KEY_PASS \
"${IMAGE_NAME}"
echo "============================================"
echo "修复文件权限..."
echo "============================================"
# Fix permissions so you can modify build directory without sudo next time
sudo chown -R $(id -u):$(id -g) "${BUILD_DIR}" 2>/dev/null || true
echo "✓ 构建完成!"
echo "构建输出位置: ${BUILD_DIR}"
echo ""
echo "============================================"
echo "版本验证提示"
echo "============================================"
echo "请验证以下信息确保更新内容已实际编译:"
echo "1. 检查APK版本号:安装后查看 设置→关于→版本信息"
echo "2. 检查构建时间:ls -la ${BUILD_DIR}/shadow_build_dir/android-build/build/outputs/apk/release/android-build-release-signed.apk"
echo "3. 确认版本号显示为 5.0.6 且构建时间正确"
echo "============================================"
对应的 Dockerfile:
以下是 Android 构建使用的 Dockerfile-build-android:
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
COPY tools/setup/install-dependencies-debian.sh /tmp/qt/
RUN chmod +x /tmp/qt/*.sh && /tmp/qt/install-dependencies-debian.sh
# Configure DNS for better network connectivity in Docker container
# Note: Docker will override this, but we'll test with available tools
# Set environment variables for Android SDK, NDK, and paths
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV ANDROID_NDK_ROOT=$ANDROID_SDK_ROOT/ndk/25.1.8937393
ENV ANDROID_HOME=$ANDROID_SDK_ROOT
ENV ANDROID_BUILD_TOOLS=$ANDROID_SDK_ROOT/build-tools/34.0.0
# Set apt-get to non-interactive and configure time zone
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
# Configure time zone and install dependencies
# Use official Ubuntu sources only
RUN echo "=== Verifying DNS configuration ===" && \
cat /etc/resolv.conf && \
echo "=== Updating package lists ===" && \
apt-get update && \
apt-get install -y tzdata && \
ln -fs /usr/share/zoneinfo/$TZ /etc/localtime && \
dpkg-reconfigure --frontend noninteractive tzdata && \
apt-get install -y \
apt-utils \
build-essential \
libpulse-dev \
libxcb-glx0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-render0 \
libxcb-shape0 \
libxcb-shm0 \
libxcb-sync1 \
libxcb-util1 \
libxcb-xfixes0 \
libxcb-xinerama0 \
libxcb1 \
libxkbcommon-dev \
libxkbcommon-x11-0 \
libxcb-xkb-dev \
python3 \
python3-pip \
wget \
unzip \
git \
openjdk-17-jdk \
curl \
locales \
ninja-build \
software-properties-common \
lsb-release
# Install newer CMake version
RUN wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc 2>/dev/null | gpg --dearmor - | tee /etc/apt/trusted.gpg.d/kitware.gpg >/dev/null && \
apt-add-repository "deb https://apt.kitware.com/ubuntu/ $(lsb_release -cs) main" && \
apt-get update && \
apt-get install -y cmake
# Set JAVA_HOME and update PATH
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
# Install Android SDK and NDK
RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools/latest && \
wget https://dl.google.com/android/repository/commandlinetools-linux-12266719_latest.zip -O /opt/cmdline-tools.zip && \
unzip /opt/cmdline-tools.zip -d $ANDROID_SDK_ROOT/cmdline-tools && \
mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools/* $ANDROID_SDK_ROOT/cmdline-tools/latest/ && \
rm -rf $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools && \
rm /opt/cmdline-tools.zip && \
yes | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT --licenses && \
$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_SDK_ROOT "platform-tools" "platforms;android-34" "build-tools;34.0.0" "ndk;25.1.8937393"
# Build arguments for Qt version selection
ARG QGC_ENABLE_HERELINK=OFF
ARG QT_VERSION_6_6_3="6.6.3"
# Qt setup and environment variables
# Use Qt 6.6.3 as default (known to work)
ENV QT_VERSION="6.6.3"
ENV QT_PATH="/opt/Qt"
ENV QT_HOST="linux"
ENV QT_HOST_ARCH="gcc_64"
ENV QT_HOST_ARCH_DIR="linux_gcc_64"
ENV QT_TARGET="android"
ENV QT_TARGET_ARCH_ARMV7="android_armv7"
ENV QT_TARGET_ARCH_ARM64="android_arm64_v8a"
ENV QT_MODULES="qtcharts qtpositioning qtspeech qt5compat qtmultimedia qtserialport qtimageformats qtshadertools qtconnectivity qtquick3d qtsensors qtlocation"
# Install aqtinstall
RUN python3 -m pip install setuptools wheel py7zr aqtinstall && \
mkdir -p $QT_PATH
# Install Qt desktop version with retry logic (split into separate RUN for better caching)
RUN export QT_VERSION="$QT_VERSION_6_6_3" && \
echo "=== Installing Qt Desktop $QT_VERSION ===" && \
for i in 1 2 3; do \
echo "Attempt $i of 3..." && \
aqt install-qt $QT_HOST desktop $QT_VERSION $QT_HOST_ARCH -O $QT_PATH -m $QT_MODULES && break || \
if [ $i -eq 3 ]; then \
echo "ERROR: Failed to install Qt desktop version after 3 attempts" && exit 1; \
else \
echo "Retrying in 5 seconds..." && sleep 5; \
fi \
done
# Install Qt Android ARMv7 version with retry logic
RUN export QT_VERSION="$QT_VERSION_6_6_3" && \
echo "=== Installing Qt Android ARMv7 $QT_VERSION ===" && \
for i in 1 2 3; do \
echo "Attempt $i of 3..." && \
aqt install-qt $QT_HOST $QT_TARGET $QT_VERSION $QT_TARGET_ARCH_ARMV7 -O $QT_PATH -m $QT_MODULES --autodesktop && break || \
if [ $i -eq 3 ]; then \
echo "ERROR: Failed to install Qt Android ARMv7 version after 3 attempts" && exit 1; \
else \
echo "Retrying in 5 seconds..." && sleep 5; \
fi \
done
# Install Qt Android ARM64 version with retry logic
RUN export QT_VERSION="$QT_VERSION_6_6_3" && \
echo "=== Installing Qt Android ARM64 $QT_VERSION ===" && \
for i in 1 2 3; do \
echo "Attempt $i of 3..." && \
aqt install-qt $QT_HOST $QT_TARGET $QT_VERSION $QT_TARGET_ARCH_ARM64 -O $QT_PATH -m $QT_MODULES --autodesktop && break || \
if [ $i -eq 3 ]; then \
echo "ERROR: Failed to install Qt Android ARM64 version after 3 attempts" && exit 1; \
else \
echo "Retrying in 5 seconds..." && sleep 5; \
fi \
done && \
echo "=== Qt installation completed successfully ==="
# Set Qt-related environment variables for ARMv7 and ARM64 architectures
# Using Qt 6.6.3
RUN export QT_VERSION="$QT_VERSION_6_6_3" && \
echo "export QT_ROOT_DIR_ARMV7=$QT_PATH/$QT_VERSION/$QT_TARGET_ARCH_ARMV7" >> /etc/environment && \
echo "export QT_ROOT_DIR_ARM64=$QT_PATH/$QT_VERSION/$QT_TARGET_ARCH_ARM64" >> /etc/environment && \
echo "export QT_HOST_PATH=$QT_PATH/$QT_VERSION/$QT_HOST_ARCH" >> /etc/environment
# Set default values (will be overridden by the RUN command above)
ENV QT_ROOT_DIR_ARMV7=$QT_PATH/6.6.3/$QT_TARGET_ARCH_ARMV7
ENV QT_ROOT_DIR_ARM64=$QT_PATH/6.6.3/$QT_TARGET_ARCH_ARM64
ENV QT_HOST_PATH=$QT_PATH/6.6.3/$QT_HOST_ARCH
ENV QT_PLUGIN_PATH_ARMV7=$QT_ROOT_DIR_ARMV7/plugins
ENV QT_PLUGIN_PATH_ARM64=$QT_ROOT_DIR_ARM64/plugins
ENV QML2_IMPORT_PATH_ARMV7=$QT_ROOT_DIR_ARMV7/qml
ENV QML2_IMPORT_PATH_ARM64=$QT_ROOT_DIR_ARM64/qml
ENV PKG_CONFIG_PATH_ARMV7=$QT_ROOT_DIR_ARMV7/lib/pkgconfig:$PKG_CONFIG_PATH
ENV PKG_CONFIG_PATH_ARM64=$QT_ROOT_DIR_ARM64/lib/pkgconfig:$PKG_CONFIG_PATH
ENV LD_LIBRARY_PATH_ARMV7=$QT_ROOT_DIR_ARMV7/lib:$LD_LIBRARY_PATH
ENV LD_LIBRARY_PATH_ARM64=$QT_ROOT_DIR_ARM64/lib:$LD_LIBRARY_PATH
# Consolidate PATH settings
ENV PATH=$JAVA_HOME/bin:/usr/lib/ccache:$QT_HOST_PATH/bin:$QT_ROOT_DIR_ARMV7/bin:$QT_ROOT_DIR_ARM64/bin:$PATH:$ANDROID_SDK_ROOT/tools:$ANDROID_SDK_ROOT/platform-tools:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_BUILD_TOOLS:$ANDROID_NDK_ROOT
RUN locale-gen en_US.UTF-8 && update-locale LANG=en_US.UTF-8
RUN git config --global --add safe.directory /project/source
# Set working directory
WORKDIR /project/build
# Build the project
CMD echo "=== Build Environment Verification ===" && \
echo "QGC_ENABLE_HERELINK: $QGC_ENABLE_HERELINK" && \
echo "Android SDK: $ANDROID_SDK_ROOT" && \
echo "Android NDK: $ANDROID_NDK_ROOT" && \
echo "Qt Host Path: $QT_HOST_PATH" && \
ls -la $ANDROID_SDK_ROOT || (echo "ERROR: Android SDK not found" && exit 1) && \
ls -la $ANDROID_NDK_ROOT || (echo "ERROR: Android NDK not found" && exit 1) && \
ls -la $QT_HOST_PATH || (echo "ERROR: Qt host installation not found" && exit 1) && \
echo "=== Creating build directory ===" && \
mkdir -p /workspace/build/shadow_build_dir && \
cd /workspace/build/shadow_build_dir && \
echo "=== Running CMake configuration ===" && \
qt-cmake -S /project/source -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DQT_HOST_PATH=$QT_HOST_PATH \
-DQT_ANDROID_BUILD_ALL_ABIS=OFF \
-DQT_ANDROID_ABIS="armeabi-v7a;arm64-v8a" \
-DQT_DEBUG_FIND_PACKAGE=ON \
-DANDROID_PLATFORM=android-28 \
-DANDROID_BUILD_TOOLS=$ANDROID_SDK_ROOT/build-tools/34.0.0 \
-DANDROID_SDK_ROOT=$ANDROID_SDK_ROOT \
-DQT_ANDROID_SIGN_APK=${QT_ANDROID_SIGN_APK:-ON} \
-DQGC_ENABLE_HERELINK=$QGC_ENABLE_HERELINK \
-DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK_ROOT/build/cmake/android.toolchain.cmake || (echo "ERROR: CMake configuration failed" && exit 1) && \
echo "=== Starting build process ===" && \
cmake --build . --target all --config Release || (echo "ERROR: Build failed" && exit 1) && \
echo "=== Build completed successfully ==="
脚本特点:
- 交互式构建模式选择(增量编译/完全清理/重新构建镜像)
- 自动配置 APK 签名参数
- Herelink 支持可选配置
- 智能缓存管理
- 自动权限修复
4.2 Ubuntu 构建脚本
以下是修改版的 Ubuntu 构建脚本 run-docker-ubuntu.sh:
#!/usr/bin/env bash
# Exit immediately if a command exits with a non-zero status
set -e
# Define variables for better maintainability
DOCKERFILE_PATH="./deploy/docker/Dockerfile-build-ubuntu"
IMAGE_NAME="qgc-ubuntu-docker"
SOURCE_DIR="$(pwd)"
BUILD_DIR="${SOURCE_DIR}/build"
CCACHE_DIR="${SOURCE_DIR}/.ccache"
CMAKE_CACHE_DIR="${SOURCE_DIR}/.cmake-cache"
# Default values
REBUILD_IMAGE=false
CLEAN_BUILD=false
FULL_CLEAN=false
# Interactive mode for cleanup selection
echo "============================================"
echo "QGroundControl Ubuntu 构建选项"
echo "============================================"
echo ""
echo "请选择构建模式:"
echo "1) 增量编译 (最快,保留所有缓存) - 日常开发推荐"
echo "2) 完全清理 (删除构建、缓存、Docker镜像) - 30-60分钟"
echo "3) 重新构建Docker镜像"
echo "4) 退出"
echo ""
while true; do
read -p "请输入选择 (1-4): " choice
case $choice in
1)
echo "选择: 增量编译"
break
;;
2)
echo "选择: 完全清理"
FULL_CLEAN=true
CLEAN_BUILD=true
REBUILD_IMAGE=true
break
;;
3)
echo "选择: 重新构建Docker镜像"
REBUILD_IMAGE=true
break
;;
4)
echo "退出脚本"
exit 0
;;
*)
echo "无效选择,请输入 1-4"
;;
esac
done
echo ""
# Handle full clean: delete everything
if [ "$FULL_CLEAN" = true ]; then
echo "============================================"
echo "执行完全清理..."
echo "============================================"
# Delete build directory
if [ -d "${BUILD_DIR}" ]; then
echo "清理构建目录: ${BUILD_DIR}"
sudo rm -rf "${BUILD_DIR}" 2>/dev/null || true
echo "✓ 构建目录已清理"
fi
# Delete ccache
if [ -d "${CCACHE_DIR}" ]; then
echo "清理ccache缓存: ${CCACHE_DIR}"
sudo rm -rf "${CCACHE_DIR}" 2>/dev/null || true
echo "✓ ccache缓存已清理"
fi
# Delete cmake cache
if [ -d "${CMAKE_CACHE_DIR}" ]; then
echo "清理cmake缓存: ${CMAKE_CACHE_DIR}"
sudo rm -rf "${CMAKE_CACHE_DIR}" 2>/dev/null || true
echo "✓ cmake缓存已清理"
fi
# Delete Docker image
if docker image inspect "${IMAGE_NAME}" > /dev/null 2>&1; then
echo "删除Docker镜像: ${IMAGE_NAME}"
docker rmi "${IMAGE_NAME}" 2>/dev/null || true
echo "✓ Docker镜像已删除"
fi
echo "完全清理完成!"
echo ""
fi
# Create cache directories if they don't exist
mkdir -p "${CCACHE_DIR}"
mkdir -p "${CMAKE_CACHE_DIR}"
mkdir -p "${BUILD_DIR}"
# Check if Docker image exists, build only if needed
if ! docker image inspect "${IMAGE_NAME}" > /dev/null 2>&1 || [ "$REBUILD_IMAGE" = true ]; then
if [ "$REBUILD_IMAGE" = true ]; then
echo "============================================"
echo "重新构建Docker镜像..."
echo "============================================"
else
echo "============================================"
echo "构建Docker镜像..."
echo "============================================"
fi
docker build --file "${DOCKERFILE_PATH}" -t "${IMAGE_NAME}" "${SOURCE_DIR}"
echo "✓ Docker镜像构建完成"
echo ""
fi
# Clean build artifacts only if requested or for QML changes
if [ "$CLEAN_BUILD" = true ] && [ "$FULL_CLEAN" = false ]; then
echo "============================================"
echo "清理构建目录..."
echo "============================================"
sudo rm -rf "${BUILD_DIR}"/* 2>/dev/null || true
echo "✓ 构建目录已清理"
echo ""
elif [ -d "${BUILD_DIR}" ] && [ "$FULL_CLEAN" = false ]; then
# Only clean QML-related artifacts for incremental builds
echo "清理QML相关文件..."
sudo rm -rf "${BUILD_DIR}/qml/" \
"${BUILD_DIR}"/*.qrc \
"${BUILD_DIR}"/*_autogen/ \
"${BUILD_DIR}/qgroundcontrol.qrc" 2>/dev/null || true
echo "✓ QML相关文件已清理"
fi
# Stop any running QGroundControl instances before building
QGC_PROCESSES=$(pgrep -f "QGroundControl|qgroundcontrol" || true)
if [ -n "${QGC_PROCESSES}" ]; then
echo "============================================"
echo "停止运行中的QGroundControl进程..."
echo "============================================"
# Try graceful shutdown first (SIGTERM)
echo "发送优雅关闭信号..."
pkill -TERM -f "QGroundControl|qgroundcontrol" 2>/dev/null || true
# Wait up to 5 seconds for graceful shutdown
echo "等待进程优雅关闭..."
for i in {1..5}; do
if ! pgrep -f "QGroundControl|qgroundcontrol" > /dev/null 2>&1; then
echo "✓ 进程已优雅关闭"
break
fi
echo "等待中... ($i/5)"
sleep 1
done
# Force kill if still running
if pgrep -f "QGroundControl|qgroundcontrol" > /dev/null 2>&1; then
echo "强制终止进程..."
pkill -KILL -f "QGroundControl|qgroundcontrol" 2>/dev/null || true
sleep 1
echo "✓ 进程已强制终止"
fi
echo ""
fi
# Run the Docker container with necessary permissions and volume mounts
echo "============================================"
echo "启动Docker容器进行构建..."
echo "============================================"
docker run \
--rm \
--cap-add SYS_ADMIN \
--device /dev/fuse \
--security-opt apparmor:unconfined \
-v "${SOURCE_DIR}:/project/source" \
-v "${BUILD_DIR}:/project/build" \
-v "${CCACHE_DIR}:/ccache" \
-v "${CMAKE_CACHE_DIR}:/cmake-cache" \
-e CCACHE_DIR=/ccache \
"${IMAGE_NAME}"
echo "============================================"
echo "修复文件权限..."
echo "============================================"
# Fix permissions so you can modify build directory without sudo next time
sudo chown -R $(id -u):$(id -g) "${BUILD_DIR}" "${CCACHE_DIR}" "${CMAKE_CACHE_DIR}" 2>/dev/null || true
echo "✓ 构建完成!"
echo "构建输出位置: ${BUILD_DIR}"
echo ""
echo "============================================"
echo "版本验证提示"
echo "============================================"
echo "请验证以下信息确保更新内容已实际编译:"
echo "1. 检查版本号:运行 ./${BUILD_DIR}/AppDir/usr/bin/QGroundControl 后查看 帮助→关于QGroundControl"
echo "2. 检查构建时间:ls -la ${BUILD_DIR}/AppDir/usr/bin/QGroundControl"
echo "3. 确认版本号显示为 5.0.6 且构建时间正确"
echo "============================================"
对应的 Dockerfile:
以下是 Ubuntu 构建使用的 Dockerfile-build-ubuntu:
FROM ubuntu:22.04
ARG QT_VERSION=6.8.3
ARG QT_MODULES="qtcharts qtlocation qtpositioning qtspeech qt5compat qtmultimedia qtserialport qtimageformats qtshadertools qtconnectivity qtquick3d qtsensors"
ENV DEBIAN_FRONTEND noninteractive
ENV DISPLAY :99
ENV QT_PATH /opt/Qt
ENV QT_DESKTOP $QT_PATH/${QT_VERSION}/gcc_64
ENV PATH /usr/lib/ccache:$QT_DESKTOP/bin:$PATH
COPY tools/setup/install-dependencies-debian.sh /tmp/qt/
RUN /tmp/qt/install-dependencies-debian.sh
COPY tools/setup/install-qt-debian.sh /tmp/qt/
RUN /tmp/qt/install-qt-debian.sh
RUN locale-gen en_US.UTF-8 && dpkg-reconfigure locales
RUN git config --global --add safe.directory /project/source
WORKDIR /project/build
CMD cmake -S /project/source -B . -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache ; \
cmake --build . --target all --config Release ; \
cmake --install . --config Release
脚本特点:
- 交互式构建模式选择
- 智能缓存管理(ccache + cmake cache)
- 自动进程管理(优雅关闭运行中的QGC)
- QML文件增量清理
- 完整的权限修复
4.3 脚本使用说明
Android 脚本使用:
# 给脚本执行权限
chmod +x run-docker-android.sh
# 运行脚本
./run-docker-android.sh
Ubuntu 脚本使用:
# 给脚本执行权限
chmod +x run-docker-ubuntu.sh
# 运行脚本
./run-docker-ubuntu.sh
5. 参考文档
QGroundControl 基于官方文档的容器化构建
本文档基于 QGroundControl v5.0.6 Stable 的二次开发,使用最简单稳定的容器构建方法,也是官方强烈推荐的构建方法。
环境要求
- Linux(已在 Ubuntu 上验证)
- Git、Docker、bash
获取源码
- 使用递归方式获取 v5.0.6 Stable,并自动拉取所有子模块(不要直接下载仓库 ZIP 或发布源码包,容易缺少子模块)。
git clone --recursive --branch v5.0.6 https://github.com/mavlink/qgroundcontrol.git
- 拉取完成后,仓库目录大小通常应在 400MB 以上;若明显偏小,请重新以递归方式克隆。
构建(Docker)
- 在仓库根目录执行:
./deploy/docker/run-docker-ubuntu.sh
- 首次编译耗时较长(取决于机器配置)。出现如下日志表示编译完成并生成可运行产物:
Making AppRun file executable: /project/build/AppDir/AppRun
运行与版本校验
- 可直接运行生成的
build/AppDir/AppRun:
./build/AppDir/AppRun
- 应用打开后,点击左上角图标,在下拉框底部可见版本号为
v5.0.6 64bit,以此确认构建版本正确。
常用工具与别名(可选)
- 为避免私有仓库配置繁琐,可使用 GitHub Desktop(Linux AppImage 版本)。
- 建议在
~/.bashrc中添加快捷别名:
# 编辑配置文件
nano ~/.bashrc
# 为 AppImage 增加执行权限
chmod +x ~/GitHubDesktop-linux-x86_64-3.4.13-linux1.AppImage
# 添加别名(可直接在编辑器中追加到 ~/.bashrc)
alias qgcdev='~/qgroundcontrol/build/AppDir/AppRun'
alias github='~/GitHubDesktop-linux-x86_64-3.4.13-linux1.AppImage'
# 使配置生效
source ~/.bashrc
# 快速启动
qgcdev # 打开二次开发的 QGC v5.0.6
github # 打开 GitHub Desktop for Linux
开发工作流
- 每次新增功能或修复请创建独立分支;不要直接推送到
main。 - 修改代码后,重复执行
./deploy/docker/run-docker-ubuntu.sh进行增量构建与验证。
常见问题
- 子模块缺失:确保使用
--recursive克隆;若仓库体积明显小于 400MB,请重新克隆。 - 构建失败:优先对照官方容器构建指南,确认本地 Docker 环境与网络条件正常。
参考文档
- 官方容器构建指南(中文):QGC Dev Guide / Getting Started / Container