接口总览
| 功能 | mavlink消息 |
|---|---|
| 分块下载选定的相机媒体文件列表 | TUNNEL_PROTOCOL_ID_REQUEST_DOWNLOAD_FILE_LIST |
| 通过文件索引和文件类型下载选定的相机媒体文件 | TUNNEL_PROTOCOL_ID_REQUEST_DOWNLOAD_FILE_TO_LOCAL |
| 通过文件索引删除选定的相机媒体文件 | TUNNEL_PROTOCOL_ID_REUQEST_DELETE_FIEL |
示例
#!/usr/bin/env python3
"""
通过 MAVLink TUNNEL 消息获取相机媒体文件列表(分块下载)
协议说明:
1. 发送 REQUEST_DOWNLOAD_FILE_LIST (32784) → 请求文件列表
负载: uint16 sliceStartIndex + uint16 countPerSlice (4 字节, 小端序)
2. 接收 DOWNLOAD_FILE_LIST (32785) → 文件列表中每个文件以分块方式返回
分块协议: id(1) + chunk_seq(1) + data(126)
相同 id 的块属于同一个文件, 按 chunk_seq 拼接后即为 T_DjiCameraManagerFileListInfo
3. 发送 REQUEST_DOWNLOAD_FILE_TO_LOCAL (32786) → 请求下载文件到设备本地
负载: uint32 fileIndex + uint8 fileType (5 字节, 小端序)
注意: 文件内容存储在设备本地磁盘, 不通过 TUNNEL 回传
T_DjiCameraManagerFileListInfo 内存布局 (64-bit, 小端序, 无 pack):
偏移 大小 字段
0 256 fileName[256]
256 4 fileSize (uint32)
260 4 fileIndex (uint32)
264 1 createTime.second
265 1 createTime.minute
266 1 createTime.hour
267 1 createTime.day
268 1 createTime.month
269 1 (padding)
270 2 createTime.year (uint16)
272 4 type (E_DjiCameraMediaFileType, uint32)
276 16 attributeData
292 1 subFileListTotalNum
293 3 (padding)
296 8 subFileListInfo (指针, 不可用)
总计: 304 字节
Usage:
python tunnel_file_download.py [--list] [--download INDEX] [--file-type TYPE]
"""
import argparse
import struct
import time
import os
from collections import defaultdict
from pymavlink import mavutil
# ============================================================================
# MAVLink 组件 ID
# ============================================================================
MAV_COMP_ID_DEFAULT = 12 # DJI 指定组件 ID
# ============================================================================
# TUNNEL 协议 ID (与 mavlink_tool.h 中 TUNNEL_PROTOCOL_ID 枚举一致)
# ============================================================================
TUNNEL_PROTOCOL_ID_REQUEST_DOWNLOAD_FILE_LIST = 32784 # 请求文件列表
TUNNEL_PROTOCOL_ID_DOWNLOAD_FILE_LIST = 32785 # 文件列表响应 (分块)
TUNNEL_PROTOCOL_ID_REQUEST_DOWNLOAD_FILE_TO_LOCAL = 32786 # 请求下载文件到本地
# ============================================================================
# 分片参数 (与 E_DjiCameraManagerFileListCountPerSlice 枚举一致)
# ============================================================================
FILE_LIST_COUNT_60_PER_SLICE = 60
FILE_LIST_COUNT_120_PER_SLICE = 120
FILE_LIST_COUNT_ALL_PER_SLICE = 0xFFFF
# ============================================================================
# T_DjiCameraManagerFileListInfo 结构体常量
# ============================================================================
DJI_FILE_NAME_SIZE_MAX = 256
# 结构体在 64-bit ARM/Linux 上的 sizeof (通过内存布局计算)
FILE_LIST_INFO_SIZE = 304 # bytes
# ============================================================================
# 媒体文件类型 (E_DjiCameraMediaFileType)
# ============================================================================
MEDIA_FILE_TYPE_NAMES = {
0: "JPEG",
1: "DNG",
2: "MOV",
3: "MP4",
4: "PANORAMA_JPEG",
5: "TIFF_SEQ",
6: "MP4_IR",
7: "JPEG_IR",
8: "M4A", # M4D 可见光 JPEG
9: "IR", # M4D 红外
10: "WIDE", # M4D 广角
14: "PHOTO_DNG",
15: "PHOTO_TIF",
16: "PHOTO_TIF_SEQ",
17: "PHOTO_SSD_RAW",
18: "PHOTO_SSD_RAW_MULTI",
19: "VIDEO_ORG",
20: "PHOTO_ORG",
21: "AUDIO_WAV",
22: "VIDEO_DNG",
23: "VIDEO_SSD_RAW",
24: "PHOTO_TIFF_8K",
25: "PHOTO_MP4",
26: "PHOTO_JPEG_8K",
27: "PHOTO_DNG_8K",
32: "ORG_FILE", # >= 32 表示原始文件
}
def parse_file_list_info(data: bytes) -> dict:
"""
从原始字节解析 T_DjiCameraManagerFileListInfo 结构体
Args:
data: 文件列表信息的原始字节 (至少 304 字节)
Returns:
dict: 解析后的文件信息
"""
if len(data) < FILE_LIST_INFO_SIZE:
raise ValueError(f"数据不足: 需要 {FILE_LIST_INFO_SIZE} 字节, 实际 {len(data)} 字节")
offset = 0
# fileName: 256 bytes, 以 null 结尾
raw_name = data[offset:offset + DJI_FILE_NAME_SIZE_MAX]
offset += DJI_FILE_NAME_SIZE_MAX
null_pos = raw_name.find(b'\x00')
file_name = raw_name[:null_pos].decode('utf-8', errors='replace') if null_pos >= 0 else raw_name.decode('utf-8', errors='replace')
# fileSize: uint32_t LE
file_size = struct.unpack_from('<I', data, offset)[0]
offset += 4
# fileIndex: uint32_t LE
file_index = struct.unpack_from('<I', data, offset)[0]
offset += 4
# createTime:
# second(1) + minute(1) + hour(1) + day(1) + month(1) + pad(1) + year(2) = 8 bytes
second, minute, hour, day, month = struct.unpack_from('<BBBBB', data, offset)
offset += 5
# padding 1 byte (for uint16 alignment)
offset += 1
year = struct.unpack_from('<H', data, offset)[0]
offset += 2
# type: uint32_t LE (E_DjiCameraMediaFileType)
file_type = struct.unpack_from('<I', data, offset)[0]
offset += 4
# attributeData: 16 bytes (union, raw)
attribute_data = data[offset:offset + 16]
offset += 16
# subFileListTotalNum: uint8_t
sub_file_count = data[offset]
offset += 1
# padding 3 bytes + subFileListInfo pointer 8 bytes (跳过)
# offset += 3 + 8
type_name = MEDIA_FILE_TYPE_NAMES.get(file_type, f"UNKNOWN({file_type})")
return {
'file_name': file_name,
'file_size': file_size,
'file_index': file_index,
'create_time': f"{year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}",
'year': year, 'month': month, 'day': day, 'hour': hour, 'minute': minute, 'second': second,
'file_type': file_type,
'type_name': type_name,
'sub_file_count': sub_file_count,
'raw_bytes': data[:FILE_LIST_INFO_SIZE],
}
def send_file_list_request(mavlink_conn, slice_start_index=0, count_per_slice=FILE_LIST_COUNT_ALL_PER_SLICE):
"""
发送请求文件列表命令
Args:
mavlink_conn: MAVLink 连接对象
slice_start_index: 分片起始索引 (0 表示从头开始)
count_per_slice: 每片文件数量 (60/120/0xFFFF)
"""
# 构建负载: uint16 sliceStartIndex + uint16 countPerSlice (小端序)
payload = struct.pack('<HH', slice_start_index, count_per_slice)
print(f"\n[REQUEST] 请求文件列表: sliceStartIndex={slice_start_index}, countPerSlice={count_per_slice}")
send_tunnel_message(mavlink_conn, payload, TUNNEL_PROTOCOL_ID_REQUEST_DOWNLOAD_FILE_LIST)
def send_download_request(mavlink_conn, file_index, file_type=32):
"""
发送下载文件到本地命令
注意: 此命令仅触发设备端的本地下载, 文件内容不会通过 TUNNEL 回传。
Args:
mavlink_conn: MAVLink 连接对象
file_index: 文件索引
file_type: 文件类型 (>=32 表示原始文件, <32 表示子文件)
"""
# 构建负载: uint32 fileIndex + uint8 fileType (小端序)
payload = struct.pack('<IB', file_index, file_type)
# 确保是 5 字节
payload = payload.ljust(5, b'\x00')[:5]
print(f"\n[REQUEST] 请求下载文件: fileIndex={file_index}, fileType={file_type}")
send_tunnel_message(mavlink_conn, payload, TUNNEL_PROTOCOL_ID_REQUEST_DOWNLOAD_FILE_TO_LOCAL)
def send_tunnel_message(mavlink_conn, payload: bytes, message_type: int):
"""
通过 MAVLink TUNNEL 消息发送数据
Args:
mavlink_conn: MAVLink 连接对象
payload: 要发送的数据负载
message_type: TUNNEL payload_type
"""
if len(payload) > 128:
raise ValueError(f"Payload 过大: {len(payload)} 字节 (最大 128)")
# 填充到 128 字节
payload_bytes = payload.ljust(128, b'\x00')
mavlink_conn.mav.tunnel_send(
target_system=0,
target_component=MAV_COMP_ID_DEFAULT,
payload_length=len(payload),
payload_type=message_type,
payload=payload_bytes
)
hex_str = ' '.join(f'{b:02X}' for b in payload[:len(payload)])
print(f" [TUNNEL SEND] type={message_type}, len={len(payload)}, data=[{hex_str}]")
def receive_file_list(mavlink_conn, timeout: float = 60.0):
"""
接收并解析文件列表
文件列表中的每个文件通过分块协议发送:
- 每个块包含: id(1) + chunk_seq(1) + data(126)
- 相同 id 的块属于同一个文件
- 按 chunk_seq 拼接后得到 T_DjiCameraManagerFileListInfo 的原始字节
Args:
mavlink_conn: MAVLink 连接对象
timeout: 接收超时时间(秒)
Returns:
list: 解析后的文件信息列表
"""
print("\n" + "=" * 70)
print("等待接收文件列表...")
print("=" * 70)
# 按消息 id 收集分块
# { msg_id: { chunk_seq: bytes } }
chunk_buffers = defaultdict(dict)
start_time = time.time()
received_count = 0
file_count_estimate = None
try:
while True:
elapsed = time.time() - start_time
if elapsed >= timeout:
print(f"\n接收超时 ({timeout}秒)")
break
msg = mavlink_conn.recv_match(type='TUNNEL', blocking=True, timeout=1.0)
if msg is None:
continue
payload_type = msg.payload_type
# 只处理文件列表响应 (32785)
if payload_type != TUNNEL_PROTOCOL_ID_DOWNLOAD_FILE_LIST:
# 打印其他 TUNNEL 消息以帮助调试
if payload_type != 0:
payload_bytes = bytes(msg.payload)
payload_len = msg.payload_length
actual_data = payload_bytes[:payload_len]
hex_str = ' '.join(f'{b:02X}' for b in actual_data)
ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in actual_data)
print(f" [TUNNEL] 收到其他类型消息: type={payload_type}, len={payload_len}")
print(f" Hex: [{hex_str}]")
print(f" ASCII: {ascii_str}")
continue
received_count += 1
# 解析分块数据
payload_bytes = bytes(msg.payload)
payload_len = msg.payload_length
if payload_len < 2:
print(f" [WARN] payload 太短: {payload_len} 字节, 跳过")
continue
# 分块头: id (1 byte) + chunk_seq (1 byte)
chunk_id = payload_bytes[0]
chunk_seq = payload_bytes[1]
# 分块数据: 剩余的 payload
chunk_data_size = min(payload_len - 2, 126)
chunk_data = payload_bytes[2:2 + chunk_data_size]
chunk_buffers[chunk_id][chunk_seq] = chunk_data
# 计算总块数估算 (每个文件 304 字节 / 126 = 3 块)
print(f" [CHUNK] #{received_count}: id={chunk_id}, seq={chunk_seq}, "
f"data_len={len(chunk_data)}")
except KeyboardInterrupt:
print("\n\n用户中断接收")
except Exception as e:
print(f"\n接收异常: {e}")
print(f"\n共接收 {received_count} 个分块, {len(chunk_buffers)} 个消息组")
# ========================================================================
# 重组并解析每个文件的完整信息
# ========================================================================
file_list = []
# 按 id 排序以保证文件顺序 (id 是递增的)
for chunk_id in sorted(chunk_buffers.keys()):
chunks = chunk_buffers[chunk_id]
# 按 chunk_seq 排序并拼接
sorted_seqs = sorted(chunks.keys())
full_data = b''
for seq in sorted_seqs:
full_data += chunks[seq]
print(f"\n [ASSEMBLE] id={chunk_id}: {len(sorted_seqs)} 块 → {len(full_data)} 字节")
if len(full_data) < FILE_LIST_INFO_SIZE:
print(f" [WARN] id={chunk_id}: 数据不足 ({len(full_data)} < {FILE_LIST_INFO_SIZE}), 跳过")
continue
try:
info = parse_file_list_info(full_data[:FILE_LIST_INFO_SIZE])
file_list.append(info)
except Exception as e:
print(f" [ERROR] id={chunk_id}: 解析失败: {e}")
return file_list
def format_file_size(size_bytes: int) -> str:
"""格式化文件大小"""
if size_bytes >= 1024 * 1024 * 1024:
return f"{size_bytes / (1024 ** 3):.2f} GB"
elif size_bytes >= 1024 * 1024:
return f"{size_bytes / (1024 ** 2):.2f} MB"
elif size_bytes >= 1024:
return f"{size_bytes / 1024:.2f} KB"
else:
return f"{size_bytes} B"
def print_file_list(file_list: list):
"""打印文件列表"""
if not file_list:
print("\n[结果] 未发现任何文件 (SD 卡可能为空)")
return
print("\n" + "=" * 90)
print(f"相机媒体文件列表 (共 {len(file_list)} 个文件)")
print("=" * 90)
print(f"{'#':>4} {'索引':>7} {'文件名':<30} {'大小':>10} {'时间':<20} {'类型':>10} {'子文件':>6}")
print("-" * 90)
for i, f in enumerate(file_list):
size_str = format_file_size(f['file_size'])
# 截断过长的文件名
name = f['file_name'][:28] + '..' if len(f['file_name']) > 30 else f['file_name']
print(f"{i:>4} {f['file_index']:>7} {name:<30} {size_str:>10} {f['create_time']:<20} "
f"{f['type_name']:>10} {f['sub_file_count']:>6}")
print("-" * 90)
total_size = sum(f['file_size'] for f in file_list)
print(f"总计: {len(file_list)} 个文件, {format_file_size(total_size)}")
def interactive_mode(mavlink_conn, file_list: list):
"""
交互模式: 允许用户选择下载文件
Args:
mavlink_conn: MAVLink 连接
file_list: 文件列表
"""
if not file_list:
print("没有可下载的文件")
return
while True:
print("\n" + "-" * 50)
print("操作选项:")
print(" d <编号> - 下载指定文件 (例如: d 0)")
print(" d <编号> <类型> - 指定文件类型下载 (>=32 原始文件)")
print(" l - 重新列出文件")
print(" q - 退出")
print("-" * 50)
try:
cmd = input("> ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\n退出")
break
if cmd == 'q':
break
elif cmd == 'l':
print_file_list(file_list)
continue
elif cmd.startswith('d'):
parts = cmd.split()
if len(parts) < 2:
print("用法: d <编号> [类型]")
continue
try:
idx = int(parts[1])
except ValueError:
print(f"无效的编号: {parts[1]}")
continue
if idx < 0 or idx >= len(file_list):
print(f"编号超出范围 (0-{len(file_list) - 1})")
continue
file_type = 32 # 默认下载原始文件
if len(parts) >= 3:
try:
file_type = int(parts[2])
except ValueError:
print(f"无效的类型: {parts[2]}")
continue
f = file_list[idx]
print(f"\n即将下载: [{idx}] {f['file_name']} (index={f['file_index']}, type={file_type})")
confirm = input("确认下载? (y/N): ").strip().lower()
if confirm == 'y':
send_download_request(mavlink_conn, f['file_index'], file_type)
print("下载命令已发送 (文件将存储在设备本地磁盘)")
# 短暂等待接收确认/状态消息
print("等待设备响应...")
start = time.time()
while time.time() - start < 5:
msg = mavlink_conn.recv_match(type='TUNNEL', blocking=True, timeout=0.5)
if msg and msg.payload_type != 0:
payload = bytes(msg.payload)
text = payload[:msg.payload_length].decode('utf-8', errors='replace').strip('\x00')
if text:
print(f" [响应] type={msg.payload_type}: {text}")
else:
print(f"未知命令: {cmd}")
def main():
parser = argparse.ArgumentParser(
description='MAVLink TUNNEL 相机媒体文件列表获取工具',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
%(prog)s # 列出所有文件并进入交互模式
%(prog)s --list # 仅列出文件
%(prog)s --list --slice 0 --count 60 # 每片 60 个文件
%(prog)s --download 5 --file-type 32 # 下载第 5 个文件的原始文件
%(prog)s --download 3 --file-type 0 # 下载第 3 个文件的 JPEG
%(prog)s --connect udpin:0.0.0.0:14550 # 指定连接地址
"""
)
parser.add_argument('--connect', '-c', default='udpin:0.0.0.0:14550',
help='MAVLink 连接字符串 (默认: udpin:0.0.0.0:14550)')
parser.add_argument('--list', '-l', action='store_true',
help='列出相机媒体文件列表')
parser.add_argument('--download', '-d', type=int, default=None,
help='下载指定编号的文件 (与 --list 配合使用, 或单独使用时直接按 fileIndex 下载)')
parser.add_argument('--file-type', '-t', type=int, default=32,
help='文件类型 (>=32 原始文件, <32 子文件, 默认: 32)')
parser.add_argument('--slice', type=int, default=0,
help='文件列表分片起始索引 (默认: 0)')
parser.add_argument('--count', type=int, default=FILE_LIST_COUNT_ALL_PER_SLICE,
help=f'每片文件数量 (60/120/{FILE_LIST_COUNT_ALL_PER_SLICE}=全部, 默认: {FILE_LIST_COUNT_ALL_PER_SLICE})')
parser.add_argument('--timeout', type=float, default=15.0,
help='接收文件列表的超时时间(秒) (默认: 15)')
parser.add_argument('--no-interactive', '-n', action='store_true',
help='禁用交互模式 (仅输出文件列表)')
args = parser.parse_args()
print("=" * 70)
print("MAVLink TUNNEL 相机媒体文件列表获取工具")
print("=" * 70)
# ========================================================================
# 建立 MAVLink 连接
# ========================================================================
print(f"\n连接: {args.connect}")
try:
mavlink_conn = mavutil.mavlink_connection(args.connect)
except Exception as e:
print(f"连接失败: {e}")
return 1
# 等待心跳包
print("等待心跳包...")
heartbeat = mavlink_conn.wait_heartbeat(timeout=10)
if heartbeat:
print(f"已连接! sysid={heartbeat.get_srcSystem()}, compid={heartbeat.get_srcComponent()}")
else:
print("警告: 未收到心跳包, 继续尝试...")
file_list = None
try:
# ====================================================================
# 模式 1: 仅下载文件 (直接使用 fileIndex)
# ====================================================================
if args.download is not None and not args.list:
send_download_request(mavlink_conn, args.download, args.file_type)
print("下载命令已发送 (文件将存储在设备本地磁盘)")
print("等待设备响应...")
start = time.time()
while time.time() - start < 5:
msg = mavlink_conn.recv_match(type='TUNNEL', blocking=True, timeout=0.5)
if msg and msg.payload_type not in (0, TUNNEL_PROTOCOL_ID_DOWNLOAD_FILE_LIST):
payload = bytes(msg.payload)
text = payload[:msg.payload_length].decode('utf-8', errors='replace').strip('\x00')
if text:
print(f" [响应] type={msg.payload_type}: {text}")
return 0
# ====================================================================
# 模式 2: 列出文件列表
# ====================================================================
send_file_list_request(mavlink_conn, args.slice, args.count)
# 接收文件列表
file_list = receive_file_list(mavlink_conn, timeout=args.timeout)
# 打印文件列表
print_file_list(file_list)
# ====================================================================
# 模式 3: 列出后下载指定文件
# ====================================================================
if args.download is not None and file_list and args.download < len(file_list):
f = file_list[args.download]
print(f"\n下载文件: [{args.download}] {f['file_name']} (index={f['file_index']}, type={args.file_type})")
send_download_request(mavlink_conn, f['file_index'], args.file_type)
print("下载命令已发送 (文件将存储在设备本地磁盘)")
# ====================================================================
# 交互模式
# ====================================================================
if not args.no_interactive and file_list is not None:
interactive_mode(mavlink_conn, file_list)
except KeyboardInterrupt:
print("\n\n用户中断")
except Exception as e:
print(f"\n错误: {e}")
import traceback
traceback.print_exc()
return 1
finally:
mavlink_conn.close()
print("连接已关闭")
return 0
if __name__ == "__main__":
exit(main())作者:孙渝泓 创建时间:2026-06-15 16:14
最后编辑:孙渝泓 更新时间:2026-06-18 18:17
最后编辑:孙渝泓 更新时间:2026-06-18 18:17