资讯动态

无人机RID报文解析实战:ASTM与GB/T标准数据结构对比与Python实现

发布时间:2026/10/20 17:38:22 来源:尧图企业网站定制
简介这份资源面向无人机研发、低空监管与协议解析方向的开发者围绕Remote ID数据结构与国标差异提供可运行的代码参考。内容基于ArduPilot的ArduRemoteID开源库逐层拆解Wi-Fi Beacon中Vendor Specific IE的OUI、OUI Type、Message Counter及ODID_MessagePack_encoded结构并解析Basic ID、Location、Self ID、System、Operator ID等六类OpenDroneID消息字段同时对比GB42590-2023与GB46750-2025在报文格式、字段顺序与编码方式上的要求差异指出雷迅C-RID模块的兼容现状与36个月宽限期。资源包共3个文件包含1个inscode工程配置、1个html页面与1个gitignore压缩后约7KB结构轻量便于快速导入查看与二次开发。已有147人学习适合需要理解RID广播数据、评估新旧标准合规性或完善PC端扫描工具解析能力的读者参考。1. 无人机RID数据结构解析与标准对比从抓包到落地的完整拆解第一次拿到无人机RIDRemote ID远程识别的原始报文时我盯着十六进制串看了半小时——ASTM和GB/T两套标准混在一起字段位置对不上解析出来的序列号全是乱码。这不是个例。RID作为无人机“数字车牌”2024年起在多地已进入强制合规阶段但真正动手解析过它的人并不多。这个标题要解决的核心问题是RID报文到底长什么样、两套主流标准的数据结构差异在哪、怎么用代码把原始字节变成可读字段。适合做无人机监管系统、飞控固件、反制设备以及低空数据平台的工程师也适合需要做合规检测的技术负责人。下面从报文结构讲起一路落到可复现的解析代码和标准对比表。2. RID报文结构拆解从物理层到应用层的数据链路2.1 RID的三种广播/传输方式与对应数据格式RID不是单一协议而是一套识别与广播框架。常见落地形态有三种蓝牙4 Legacy广播、蓝牙5 Long Range扩展广播、Wi-Fi Neighbor Awareness NetworkingNAN。三种方式承载的RID数据单元基本一致但封装层级不同。蓝牙4 Legacy广播把RID消息塞在AD Structure的Manufacturer Specific Data里单包有效载荷通常只有20~30字节所以一条完整RID消息往往要拆成多个广播包分片发送。蓝牙5扩展广播单包能到200多字节一条Message Pack可以完整放下。Wi-Fi NAN则走服务发现帧结构更接近TLV。我一般建议先确认目标设备用的是哪种广播方式再决定解析入口。如果拿到的是一段原始空口抓包数据第一步不是急着找序列号而是先定位广播类型。蓝牙4的AD Type是0xFFCompany ID通常为0xFFFF用于测试或特定值蓝牙5扩展广播的AD Type是0x01~0x07范围内的扩展头Wi-Fi NAN则看Public Action Frame的OUI。# 判断RID广播类型的简易逻辑基于原始AD数据 def detect_rid_transport(raw_ad: bytes) - str: raw_ad: 蓝牙AD Structure原始字节或Wi-Fi帧体 返回: bt4_legacy | bt5_extended | wifi_nan | unknown if len(raw_ad) 3: return unknown # 蓝牙AD Structure: [length][type][data...] ad_type raw_ad[1] if ad_type 0xFF: # Manufacturer Specific Data进一步看Company ID company_id int.from_bytes(raw_ad[2:4], little) if company_id 0xFFFF: return bt4_legacy elif ad_type in (0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07): # 扩展广播相关AD Type return bt5_extended # Wi-Fi NAN的OUI判断需结合帧控制字段此处简化 if raw_ad[0:3] b\x50\x6f\x9a: return wifi_nan return unknown这段代码的关键在于不要试图用一套逻辑通吃所有广播类型。先分类再进各自的解析分支。参数上raw_ad必须是完整的AD Structure字节不能只取payload蓝牙的Company ID用小端序读取这是最容易翻车的地方——很多解析库默认大端结果Company ID读出来是0xFFFF0000这种鬼东西。2.2 ASTM F3411与GB/T 41300的字段级差异两套标准都定义了RID消息的基本字段但排列顺序、编码方式、可选字段集有明显差异。ASTM F3411把消息分为Basic ID、Location/Vector、Authentication、Self-ID、System、Operator ID、Message Pack等类型每种类型有固定的Message Type编号。GB/T 41300在字段定义上参考了ASTM但做了本地化调整比如增加了特定的行业扩展位序列号编码规则也不同。最核心的差异在Basic ID消息。ASTM F3411的Basic ID结构是Message Type(1字节) ID Type(1字节) UAS ID(20字节) 保留位。UAS ID的20字节里前几个字节表示ID类型序列号/注册号/UTM UUID后面是实际内容。GB/T 41300的Basic ID在ID Type之后多了一个1字节的“ID长度”字段然后才是变长的ID内容最后有1字节校验。这意味着同一段字节流用ASTM解析器去读GB/T报文序列号会整体偏移一位读出来全是乱码。# ASTM F3411 Basic ID解析 def parse_astm_basic_id(payload: bytes) - dict: payload: 去掉广播头之后的RID消息体 结构: [Message Type:1][ID Type:1][UAS ID:20][保留:1] msg_type payload[0] id_type payload[1] uas_id_raw payload[2:22] # ID Type: 0序列号, 1注册号, 2UTM UUID, 3特定会话ID if id_type 0: # 序列号通常是ASCII去掉尾部填充的0x00 uas_id uas_id_raw.rstrip(b\x00).decode(ascii, errorsreplace) elif id_type 1: uas_id uas_id_raw.rstrip(b\x00).decode(ascii, errorsreplace) elif id_type 2: # UTM UUID是16字节二进制 uas_id uas_id_raw[:16].hex() else: uas_id uas_id_raw.hex() return { standard: ASTM_F3411, message_type: msg_type, id_type: id_type, uas_id: uas_id } # GB/T 41300 Basic ID解析 def parse_gbt_basic_id(payload: bytes) - dict: payload: 去掉广播头之后的RID消息体 结构: [Message Type:1][ID Type:1][ID Length:1][UAS ID:变长][校验:1] msg_type payload[0] id_type payload[1] id_len payload[2] uas_id_raw payload[3:3id_len] checksum payload[3id_len] if id_type in (0, 1): uas_id uas_id_raw.decode(ascii, errorsreplace) else: uas_id uas_id_raw.hex() return { standard: GBT_41300, message_type: msg_type, id_type: id_type, id_length: id_len, uas_id: uas_id, checksum: checksum }两个解析函数的入口参数都是“去掉广播头之后的RID消息体”但内部偏移完全不同。ASTM的UAS ID固定20字节GB/T的是变长。实际项目中我通常会在解析前先做一个标准判定看第3个字节是否等于0x1420如果是大概率是ASTM如果第3个字节是一个合理的长度值比如10~30且第4字节开始是ASCII可打印字符则倾向GB/T。这个判定不是100%准确但能过滤掉大部分误判。2.3 位置/矢量消息的编码精度与坐标系选择Location/Vector消息是RID里最容易被低估的部分。ASTM F3411用经纬度各4字节有符号整数表示单位是1e-7度高度用2字节无符号整数单位是0.5米。GB/T 41300在经纬度上采用了相同的精度但高度字段多了一个“高度类型”标志位用来区分椭球高和海拔高。这个差异在跨标准数据融合时会导致几十米的垂直误差。# ASTM Location/Vector解析 def parse_astm_location(payload: bytes) - dict: 结构: [MsgType:1][Status:1][Direction:2][Speed:2][VertSpeed:2] [Lat:4][Lon:4][Alt:2][Height:2][VertAcc:1][HorizAcc:1][BaroAlt:2][SpeedAcc:1][Timestamp:2] 注意实际字段顺序以标准文档为准此处为常见实现 import struct lat_raw struct.unpack(i, payload[8:12])[0] lon_raw struct.unpack(i, payload[12:16])[0] alt_raw struct.unpack(H, payload[16:18])[0] latitude lat_raw * 1e-7 longitude lon_raw * 1e-7 altitude alt_raw * 0.5 - 1000 # 偏移量-1000米 return { latitude: round(latitude, 7), longitude: round(longitude, 7), altitude_m: altitude }参数上lat_raw和lon_raw是有符号32位整数小端序。高度偏移量-1000米是ASTM的规定意味着原始值0对应-1000米原始值2000对应0米。这个偏移量在GB/T里不一定相同所以跨标准解析时不能复用同一个高度转换公式。我见过一个团队把两套标准的高度混在一起算结果无人机显示在负海拔飞行排查了一整天才发现是偏移量没对齐。3. 用Python实现RID解析器从原始字节到结构化输出3.1 环境准备与依赖选择解析RID不需要重型框架标准库的struct和binascii足够处理大部分场景。如果要做蓝牙抓包可以用pybluez或bleakWi-Fi NAN抓包则依赖scapy的NAN扩展。我一般建议先用离线数据跑通解析逻辑再对接实时抓包这样能把协议问题和抓包问题分开排查。# 创建虚拟环境并安装基础依赖 python -m venv rid_env source rid_env/bin/activate # Windows用 rid_env\Scripts\activate pip install bleak scapy constructconstruct库在定义二进制结构时比手写struct更清晰尤其适合字段多、有变长结构的RID消息。但它的学习曲线略陡如果只是做固定结构解析struct更直接。bleak用于蓝牙5扩展广播的扫描scapy用于Wi-Fi NAN帧的构造与解析。3.2 完整解析流程分片重组、消息类型分发、字段提取RID消息在蓝牙4 Legacy广播下经常分片一条完整的Message Pack可能横跨3~5个广播包。分片重组的逻辑是每个广播包带一个“消息计数器”和“分片序号”按序号拼接后再解析。这里最容易踩的坑是广播包乱序到达——蓝牙广播不保证顺序必须自己维护一个缓冲区按序号排序后再拼接。class RIDReassembler: def __init__(self): self.buffers {} # key: (mac, msg_counter) - {seq: data} def add_fragment(self, mac: str, msg_counter: int, seq: int, total: int, data: bytes): key (mac, msg_counter) if key not in self.buffers: self.buffers[key] {} self.buffers[key][seq] data if len(self.buffers[key]) total: # 所有分片到齐按seq排序拼接 ordered b.join(self.buffers[key][i] for i in range(total)) del self.buffers[key] return ordered return None def parse_message_pack(self, raw: bytes) - list: 解析Message Pack返回消息列表 messages [] offset 0 while offset len(raw): msg_type raw[offset] # 根据消息类型确定长度此处简化处理 if msg_type 0x00: # Basic ID msg_len 25 elif msg_type 0x01: # Location/Vector msg_len 25 elif msg_type 0x02: # Authentication msg_len 25 elif msg_type 0x03: # Self-ID msg_len 25 elif msg_type 0x04: # System msg_len 25 elif msg_type 0x05: # Operator ID msg_len 25 else: break if offset msg_len len(raw): break messages.append(raw[offset:offsetmsg_len]) offset msg_len return messagesRIDReassembler的核心是buffers字典键是(mac, msg_counter)值是该消息的所有分片。total参数来自广播包里的分片总数指示不同厂商的实现可能放在不同位置需要根据实际抓包调整。parse_message_pack按固定25字节切分消息这个长度是ASTM F3411的常见实现但GB/T的消息长度可能不同实际项目中要根据标准动态计算。3.3 输出格式化JSON、CSV与实时流解析结果最终要落到可用格式。做监管平台对接时JSON是首选做离线分析时CSV更方便用Excel或Pandas处理做实时监控时直接推送到消息队列。我一般会定义一个统一的输出结构把标准类型、消息类型、时间戳、关键字段都带上这样下游不用关心原始协议差异。import json from datetime import datetime def format_rid_output(parsed: dict, standard: str) - str: 统一格式化输出 output { timestamp: datetime.utcnow().isoformat() Z, standard: standard, message_type: parsed.get(message_type), uas_id: parsed.get(uas_id), latitude: parsed.get(latitude), longitude: parsed.get(longitude), altitude_m: parsed.get(altitude_m), raw_hex: parsed.get(raw_hex, ) } # 去掉None值保持输出干净 output {k: v for k, v in output.items() if v is not None} return json.dumps(output, ensure_asciiFalse) # 使用示例 astm_result parse_astm_basic_id(b\x00\x00 bSN1234567890 b\x00*8 b\x00) print(format_rid_output(astm_result, ASTM_F3411))输出里保留raw_hex是个好习惯方便后续排查解析错误。ensure_asciiFalse让中文Self-ID能正常显示。时间戳统一用UTC避免跨时区部署时出现时间混乱。4. 标准对比与选型ASTM、GB/T与行业扩展怎么选4.1 字段映射表两套标准的核心差异一览字段ASTM F3411GB/T 41300差异说明Basic ID长度固定25字节变长3ID长度1GB/T更灵活但解析需动态计算UAS ID编码固定20字节尾部补0变长带长度字段跨标准解析必须区分高度偏移-1000米可能不同混用会导致垂直误差校验字段无独立校验有1字节校验GB/T可做完整性验证消息类型编号0x00~0x05部分对齐有扩展不能直接复用类型判断广播分片支持支持分片逻辑类似但计数器位置可能不同这张表是我在实际项目中反复对照后整理的。最需要注意的是“高度偏移”和“校验字段”。ASTM没有独立校验所以解析错误只能靠字段合理性判断GB/T有校验但校验算法各厂商实现可能不同有的用简单累加和有的用CRC8。4.2 跨标准兼容的解析器设计模式如果项目需要同时支持两套标准不要写两个独立的解析器然后if-else切换。更好的做法是定义一个抽象的RIDParser基类把公共逻辑分片重组、消息分发放在基类标准特有的字段解析放在子类。这样新增标准时只需加一个子类不用动核心流程。from abc import ABC, abstractmethod class RIDParser(ABC): abstractmethod def parse_basic_id(self, payload: bytes) - dict: pass abstractmethod def parse_location(self, payload: bytes) - dict: pass def parse(self, raw: bytes) - dict: 模板方法先判类型再分发 msg_type raw[0] if msg_type 0x00: return self.parse_basic_id(raw) elif msg_type 0x01: return self.parse_location(raw) else: return {error: unsupported_message_type, raw: raw.hex()} class ASTMParser(RIDParser): def parse_basic_id(self, payload: bytes) - dict: return parse_astm_basic_id(payload) def parse_location(self, payload: bytes) - dict: return parse_astm_location(payload) class GBTParser(RIDParser): def parse_basic_id(self, payload: bytes) - dict: return parse_gbt_basic_id(payload) def parse_location(self, payload: bytes) - dict: # GB/T位置解析逻辑字段偏移不同 pass这个设计模式的好处是parse方法定义了固定流程子类只关心字段级差异。实际项目中我还会在基类里加一个detect_standard静态方法根据原始字节的特征自动选择解析器这样上层调用完全不用关心标准类型。4.3 选型建议什么场景用哪套标准如果做出口机型或对接国际UTM系统ASTM F3411是必选项。如果面向国内监管平台GB/T 41300是合规底线。如果做行业应用比如物流、巡检可以在满足基本RID要求的前提下在Self-ID或Operator ID字段里塞自定义扩展数据但要注意不要破坏标准字段的解析兼容性。我见过一个团队把自定义JSON塞进Self-ID结果长度超限导致广播包被截断接收端解析出一半JSON排查了很久。5. 避坑与排查RID解析中最容易翻车的五个点5.1 序列号读出来是乱码现象解析Basic ID时uas_id字段显示为不可打印字符或问号。原因通常是ID Type判断错误把二进制UUID当ASCII序列号解析了。解决先打印id_type的值确认是0/1ASCII还是2二进制。如果是2用.hex()而不是.decode()。5.2 经纬度偏移了几十公里现象解析出的位置和实际位置差了一个固定偏移。原因多半是字节序搞反了。ASTM和GB/T都用小端序但有些抓包工具导出的数据已经转成了大端。解决用struct.unpack(i, ...)明确指定小端不要用默认的或。5.3 分片重组后消息体不完整现象Message Pack解析到一半就断了后面的消息全是乱码。原因通常是分片序号从1开始而不是0或者total字段读错了位置。解决打印每个分片的seq和total确认序号范围。如果序号是1~N拼接时要从1开始遍历。5.4 高度值出现负数或异常大现象无人机高度显示为-500米或65535米。原因是没有减去偏移量或者偏移量用错了标准。解决确认当前解析的是ASTM还是GB/TASTM的偏移量是-1000米GB/T的可能不同。先打印原始alt_raw值再对照标准文档确认转换公式。5.5 实时抓包丢消息现象离线数据解析正常实时抓包时偶尔丢消息。原因通常是蓝牙扫描间隔设置太长或者Wi-Fi NAN的信道跳变没跟上。解决蓝牙扫描用bleak时把scan_interval调到100ms以内Wi-Fi NAN要锁定信道不要全信道扫描。6. 进阶技巧用RID数据做轨迹重建与合规校验RID数据的价值不止于解析出字段。把连续的位置消息按时间戳排序可以做轨迹重建把Basic ID和Operator ID关联可以做合规校验。我一般会写一个轻量的轨迹重建器输入是解析后的位置消息列表输出是轨迹点和统计信息。def rebuild_trajectory(location_messages: list) - dict: location_messages: 解析后的位置消息列表每条含timestamp/lat/lon/alt 返回: 轨迹点、总距离、最大速度等 import math points sorted(location_messages, keylambda x: x[timestamp]) total_distance 0.0 max_speed 0.0 for i in range(1, len(points)): p1, p2 points[i-1], points[i] # 简化的球面距离计算 dlat math.radians(p2[latitude] - p1[latitude]) dlon math.radians(p2[longitude] - p1[longitude]) a math.sin(dlat/2)**2 math.cos(math.radians(p1[latitude])) * \ math.cos(math.radians(p2[latitude])) * math.sin(dlon/2)**2 c 2 * math.asin(math.sqrt(a)) distance 6371000 * c # 地球半径米 total_distance distance # 时间差 t1 datetime.fromisoformat(p1[timestamp].replace(Z, 00:00)) t2 datetime.fromisoformat(p2[timestamp].replace(Z, 00:00)) dt (t2 - t1).total_seconds() if dt 0: speed distance / dt max_speed max(max_speed, speed) return { point_count: len(points), total_distance_m: round(total_distance, 2), max_speed_mps: round(max_speed, 2), start_time: points[0][timestamp] if points else None, end_time: points[-1][timestamp] if points else None }这个轨迹重建器用的是Haversine公式精度对RID场景足够。max_speed可以用来做合规校验——如果超过标准规定的最大速度可能意味着数据异常或无人机在超速飞行。实际项目中我会把轨迹重建和合规校验做成两个独立模块轨迹重建只负责几何计算合规校验负责对照标准阈值。合规校验的另一个重点是Basic ID和Operator ID的一致性。Basic ID里的UAS ID应该和Operator ID里的运营人标识有对应关系如果对不上可能是伪造RID或数据注入。我一般会维护一个运营人白名单解析时实时比对不在白名单里的直接告警。最后一个技巧RID数据的实时流处理不要用Python的同步循环吞吐量上不去。用asyncio配合bleak的异步扫描或者把解析逻辑用numba加速单机可以处理每秒上千条消息。如果做大规模部署解析层用Go或Rust重写Python只做上层分析这是我踩过性能坑之后的习惯。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑