MavSDK和QGC航点规划兼容性问题的解决方案

1. 问题背景

1.1 QGC 5.0.6 稳定版航点管理问题

版本兼容性说明

本解决方案针对以下版本进行了测试和优化:

QGroundControl (QGC) 5.0.6 稳定版在航点管理方面存在一些已知问题,这些问题主要影响无人机任务的规划和执行:

主要问题表现

  1. 航点数量异常增长

    • 用户上传5个航点,QGC显示15-20个航点
    • 系统自动生成额外的航点,导致任务执行异常
    • 航点序列号不连续,影响任务逻辑
  2. 航点执行异常

    • 无人机在航点处"徘徊"不继续执行
    • 航点接受半径设置无效
    • 任务执行顺序混乱
  3. 界面显示问题

    • 航点列表显示不准确
    • 航点属性显示错误
    • 任务状态更新延迟

问题影响

  • 任务可靠性下降:航点数量异常导致任务执行不可预测
  • 操作效率降低:需要手动清理多余航点
  • 安全风险增加:航点执行异常可能导致飞行安全问题

1.2 MAVSDK-Python MissionItem 与 QGC 兼容性问题

MAVSDK-Python 的 MissionItem 数据结构与 QGC 的航点解析机制之间存在兼容性问题,主要体现在数据结构差异和协议层面问题两个方面。在数据结构方面,MAVSDK-Python 使用 latitude_deglongitude_deg 字段格式,而 QGC 期望 latlng 格式,同时 MAVSDK-Python 使用枚举类型(如 CameraActionVehicleAction),而 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 的航点解析机制存在一些已知问题:

解析流程

  1. 接收 MAVLink 消息

    # QGC 接收 MISSION_ITEM_INT 消息
    def parse_mission_item(message):
        seq = message.seq
        frame = message.frame
        command = message.command
        # ... 解析其他字段
    
  2. 数据验证和转换

    # QGC 的数据验证逻辑(存在问题)
    if command == MAV_CMD_NAV_WAYPOINT:
        # 错误:某些条件下会创建额外航点
        if param1 > 0:  # 问题条件
            create_additional_waypoint()
    
  3. 航点显示和存储

    • 将解析的航点添加到任务列表
    • 更新界面显示
    • 存储到本地数据库

已知问题

  1. 自动航点生成

    • QGC 5.0.6 在某些条件下会自动生成额外航点
    • 这些航点没有正确的序列号
    • 导致任务执行异常
  2. 参数解析错误

    • 某些参数被错误解释
    • 导致航点属性不正确
    • 影响任务执行逻辑
  3. 坐标系转换问题

    • 经纬度精度处理不当
    • 坐标系转换算法错误
    • 导致航点位置偏移

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      # 任务类型

关键参数说明

  1. 坐标系 (frame)

    • MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6
    • 使用相对高度的全球坐标系
    • 经纬度精度为 1e7
  2. 命令类型 (command)

    • MAV_CMD_NAV_WAYPOINT = 16:普通航点
    • MAV_CMD_NAV_TAKEOFF = 22:起飞命令
    • MAV_CMD_NAV_LAND = 21:降落命令
  3. 参数含义

    • param1:盘旋时间(秒)
    • param2:接受半径(米)
    • param3:未使用
    • param4:偏航角(度)

兼容性要求

  1. 序列号连续性

    • 航点序列号必须连续
    • 从0开始递增
    • 不能有重复或跳跃
  2. 参数范围

    • 经纬度范围:-90 到 +90(纬度),-180 到 +180(经度)
    • 高度范围:0 到 1000 米
    • 速度范围:0.1 到 50 米/秒
  3. 命令兼容性

    • 使用标准的 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 数据流程设计

完整数据流程

  1. 数据输入阶段

    # 用户代码数据输入示例(符合航点输出协议)
    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"
            }
        ]
    }
    
  2. 数据验证阶段

    # 字段验证
    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
    
  3. 数据转换阶段

    # 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)
    
  4. 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)
    
  5. 任务上传阶段

    # 上传到飞行控制器
    await drone.mission_raw.clear_mission()  # 清除旧任务
    await drone.mission_raw.upload_mission(raw_items)  # 上传新任务
    

3.3 兼容性保证机制

QGC 兼容性保证

  1. 使用 MissionRaw 接口

    • 直接使用 MISSION_ITEM_INT 消息格式
    • 避免 QGC 的自动解析和优化
    • 确保航点数据的一致性
  2. 正确的任务清除

    # 上传前清除旧任务
    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)
    
  3. 参数范围控制

    # 确保参数在 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() 转换器

# 完整的 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:#1565c0

2.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: 查看执行结果
    end

3. 核心组件

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范围推荐
}

常见配置组合

场景采样率位深度声道数据率
语音识别(推荐)44100Hz16bit单声道86KB/s
低质量/节省带宽16000Hz16bit单声道31KB/s
高质量/专业录音48000Hz24bit立体声281KB/s
电话音质8000Hz16bit单声道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

为什么选择这些参数

  1. 44100 Hz 采样率:这是标准CD音质,能够完整保留人声频率范围(20 Hz - 20 kHz),确保语音识别系统能够准确识别语音内容。虽然16000 Hz也能满足基本需求,但44100 Hz提供了更好的音质冗余,适应不同环境下的录音质量变化。

  2. 16 bit 位深度:提供了65536个量化级别,足够捕捉人声的动态范围。8 bit(256级)会导致明显的量化噪声,而24 bit虽然更精确,但会增加50%的数据量,对语音识别来说收益有限。

  3. 单声道:语音识别主要关注频率内容和时间序列,不需要立体声的空间信息。使用单声道可以将数据量减半,显著降低网络传输负担和内存占用。

  4. 50 ms 读取间隔:在录音过程中,每50毫秒读取一次音频数据,既能及时响应录音状态变化,又不会过度占用CPU资源。过短的间隔(如10 ms)会增加CPU开销,过长的间隔(如100 ms)会导致内存缓冲区过大。

常见配置组合对比

场景采样率位深度声道数据率适用性
语音识别(推荐)44100Hz16bit单声道86KB/s✅ 最佳平衡
低质量/节省带宽16000Hz16bit单声道31KB/s⚠️ 音质可能不足
高质量/专业录音48000Hz24bit立体声281KB/s❌ 过度配置
电话音质8000Hz16bit单声道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/jsonAPI 数据交换结构清晰,易解析二进制需 Base64 编码(+33%体积)
multipart/form-data文件上传二进制高效,标准化格式复杂,需手动构建
application/octet-stream纯二进制流最简单无元数据,无法传递文件名等信息

格式结构说明

基本组成

请求头:
  Content-Type: multipart/form-data; boundary=<边界标识符>

请求体:
  --<边界标识符>                          ← 开始边界
  Content-Disposition: form-data; ...    ← 字段描述
  Content-Type: ...                      ← 内容类型
                                         ← 空行(重要!)
  [数据内容]                              ← 实际数据
  --<边界标识符>--                        ← 结束边界(多了两个横杠)

关键概念

  1. Boundary(边界):唯一标识符,不能出现在数据内容中
  2. CRLF(\r\n):每行必须以 \r\n 结尾(HTTP 标准)
  3. Content-Disposition:描述字段名(name)和文件名(filename)
  4. 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 结构,主体字段为 resulttext 等,用于承载执行结果或转写文字。

4.2 后端分层结构

  • API 接口层

    • 暴露 /voice/command/voice/transcribe 等 HTTP 接口。
    • 负责请求解析、基础校验、依赖注入以及异常到 HTTP 错误码的转换。
  • 语音处理层

    • 接收上传音频,完成格式和内容的基础检查(例如:文件是否为空、扩展名是否在允许列表中)。
    • 将音频内容写入临时介质或缓冲区,以便后续语音识别模型使用。
    • 调用本地语音识别模型(如 Whisper small@CPU,单例加载),得到转写文本。
  • 智能体与无人机控制层

    • 提供一个“无人机智能体”对象,对外暴露 process(command_text, task_type) 等高层方法。
    • 内部持有一个“机队管理器”对象,用于实际下发飞行指令(如起飞、降落、绕圈、飞往航点等)。
    • 通过外部大模型(LLM)完成自然语言到控制代码的转换,并在受控环境中执行。
  • 配置与异常处理层

    • 定义一组专门的异常类型(例如:音频为空、音频格式不支持、语音模型不可用、转写结果为空、智能体未初始化等),并由统一异常处理器转换为结构化 HTTP 响应。

4.3 语音转命令接口:逻辑流程

语音转命令接口的逻辑可以概括为以下步骤:

  1. 接收上传音频

    • 使用一个通用的文件上传参数(例如 audio_file),通过 multipart/form-data 方式接收。
    • 若文件名缺失或内容为空,抛出如 EmptyAudioError 之类的业务异常。
  2. 文件格式与内容校验

    • 允许扩展名集合如:{".wav", ".mp3", ".flac", ".m4a", ".ogg", ".webm", ".mpeg", ".mp4"}
    • 若扩展名不在允许列表中,抛出如 UnsupportedAudioFormatError 异常,并在错误信息中提示支持的格式。
    • QGC 端推荐统一上传标准 WAV(PCM, 44100Hz, 16bit, 单声道),以简化链路和调试。
  3. 写入临时介质并调用语音模型

    • 将上传的二进制内容写入一个临时路径或缓冲区,供语音识别模型使用。
    • 通过类似 get_speech_model() 的单例函数获取语音识别模型实例:
      • 首次调用时完成模型加载,并捕获缺少依赖、加载失败等情况。
      • 后续请求复用同一个模型实例,避免重复加载导致的性能问题。
    • 调用模型的 transcribe(temp_audio) 方法获得 transcribed_text
    • 无论成功与否,都在合适时机删除临时音频文件或释放缓冲区,避免磁盘/内存泄漏。
    • transcribed_text 为空,抛出如 AudioTranscribeError 的语义化异常。
  4. 调用智能体进行命令解释与执行

    • 通过依赖注入或全局管理函数获取当前的“无人机智能体”实例(例如 get_drone_agent())。
    • 若智能体尚未正确初始化(例如机队管理器不可用等),直接返回指引用户检查环境的配置提示,而不是继续执行。
    • 调用智能体的异步方法,例如:
      • agent.process(transcribed_text, task_type="control")
    • task_type="control" 场景下,智能体内部的大致逻辑为:
      1. 读取当前机队状态,只允许对“已连接无人机”进行操作。
      2. 若无可用无人机,直接返回例如“未连接任何无人机”的错误文案,而不是尝试自行连接。
      3. 构造上游约束清晰的提示词,将用户自然语言命令、可用方法列表和安全限制一同输入 LLM。
      4. 从 LLM 返回中提取 Python 控制代码片段,在受限制的命名空间内执行,仅暴露受控的机队管理对象和必要的工具方法。
      5. 汇总“生成代码文本 + 实际执行结果”,拼接成一个富文本字符串,作为最终返回值。
  5. 统一结果封装与返回

    • 将智能体返回的字符串包装到统一的响应模型中,例如:
      • { "result": "<带或不带 ANSI 颜色的执行结果文本>" }
    • 发生异常时,将内部异常转换为结构化 JSON 错误响应,包含至少:
      • 机器可读的错误类型(如 "error_type": "empty_audio"
      • 面向用户的错误提示(如 "message": "音频文件为空,请重新录制后再试"

4.4 语音转文字接口(测试接口)

除语音转命令外,后端还提供一个纯“语音转文字”能力,主要用于:

  • 调试语音识别链路(确认录音质量与模型表现)。
  • 为其他上层应用提供字幕/转写服务。

该接口的典型行为:

  1. /voice/command 相同方式接收 multipart/form-data 的音频文件。
  2. 复用相同的语音识别模型,将音频转写为文本。
  3. 返回结构化响应,例如:
    • text: 转写得到的文本内容。
    • language: 语言标识(如 "zh")。
    • duration: 音频时长的字符串表示。
    • processing_time: 服务端处理耗时的字符串表示。

4.5 端到端时序总结(后端视角)

结合前端章节中的整体时序图,从后端视角可以将关键步骤概括为:

  1. 接收请求:收到 POST /api/v1/voice/command 请求,Content-Type 为 multipart/form-data,字段名为 audio
  2. 基础校验:检查文件名、扩展名、内容长度,过滤掉明显无效请求。
  3. 语音转写:将音频内容交给本地语音识别模型,得到自然语言 transcribed_text
  4. 智能体处理:调用无人机智能体的 process() 方法,由 LLM 生成受约束的控制代码并执行,严格遵守“只操作已连接无人机、不自行连接”的规则。
  5. 结果封装:将执行结果封装成统一 JSON 响应返回前端,可包含 ANSI 颜色信息以便前端渲染。
  6. 资源清理:确保临时音频文件和中间缓冲在成功或异常场景下均被正确清理,避免长期资源泄漏。

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
JavaOpenJDK 17
Qt6.6.3
Android SDK34
Build Tools34.0.0
NDK25.1.8937393 (25B)
Platformandroid-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进行编译。

  1. NDK 兼容性 - Qt 6.6.3 与 Android NDK 25.1.8937393 有最佳兼容性
  2. Herelink 支持 - 该版本对 Herelink 设备有完整的支持
  3. 构建工具链 - 与 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 签名

为什么需要签名?

  1. 系统要求 - Android 系统强制要求所有 APK 必须签名才能安装,未签名的 APK 无法运行

  2. 应用身份 - 签名是应用的唯一标识,用于:

    • 验证应用来源和开发者身份
    • 防止应用被篡改或伪造
    • 建立应用之间的信任关系
  3. 应用更新 - 只有使用相同密钥签名的新版本才能覆盖安装旧版本

    • 更换密钥后,用户必须先卸载旧版本(会丢失数据)
    • 无法在应用市场(如 Google Play)更新应用
  4. 安全保障 - 签名机制确保 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

使用步骤:

  1. 安装 JDK(如果未安装):

    sudo apt install openjdk-17-jdk-headless -y
    
  2. 修改脚本配置:

    • 编辑 KEY_PASSWORDSTORE_PASSWORD 为您的密码
    • 根据需要修改 DN 信息(组织名称、城市等)
  3. 运行脚本:

    chmod +x create_keystore.sh
    ./create_keystore.sh
    
  4. 配置构建脚本: 将生成的配置信息添加到 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
Qt6.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?

  1. 环境一致性 - 所有依赖预装在镜像中
  2. 简化配置 - 无需手动安装 Qt、SDK、NDK
  3. 隔离构建 - 不污染主机环境
  4. 可重复性 - 任何机器都能获得相同的构建结果

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位ARM
  • QT_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 环境与网络条件正常。

参考文档