简介:这份资源面向无人机软件开发与低空监管技术学习者,聚焦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编码消息的字段含义与解码结果。资源包共3个文件,以html页面为主体呈现解析内容,辅以inscode工程配置与gitignore版本控制文件,压缩包约7KB,轻量易读。目前已有147人学习。读者可借此掌握RID报文从物理层到应用层的完整解码链路,理解GB42590-2023与GB46750-2025在数据格式、字段顺序、编码方式上的强制要求差异,并了解雷迅C-RID模块的合规现状与36个月宽限期政策,为PC端扫描工具适配新国标提供排错与重构思路。
1. 无人机RID数据结构解析与标准对比:从一段十六进制报文说起
第一次拿到无人机RID(Remote ID,远程识别)的原始报文时,我盯着串口助手里那串十六进制发了半天呆——02 19 41 42 43 ...,看着像乱码,其实每个字节都有严格定义。RID 的本质是无人机在飞行过程中对外广播的一套身份与位置信息,监管方、接收端、第三方服务都靠它识别“谁在飞、飞在哪、什么时候飞的”。它解决的是低空飞行器“看不见、管不住”的问题,适合做无人机监管终端、反制设备、飞控固件、空域管理平台的从业者。标题里的“数据结构解析”指的是把广播报文按协议字段拆开,“标准对比”则是把主流的两套规范放在一起看差异。很多人卡在第一步:报文抓到了,却不知道从哪个字节开始读。
2. RID 报文的数据结构:从字节流到可读字段
2.1 广播帧的整体布局与字段边界
RID 广播报文最常见的载体是蓝牙低功耗(BLE)的扩展广播,也有 Wi-Fi Beacon 和 Wi-Fi NAN 两种形式。不管哪种载体,载荷部分遵循同一套消息结构:一个字节的消息类型(Message Type),紧跟一个字节的协议版本,然后是具体的消息体。消息类型决定了后面怎么解析——是基本身份、位置信息、还是系统信息。
我一般先把抓到的原始字节按“消息类型 → 版本 → 消息体”三段切开,再逐段对照字段表。以基本身份消息(Basic ID)为例,消息体里包含 UA Type(无人机类型)、UAS ID Type(ID 类型)、UAS ID(实际标识,通常是序列号或会话 ID)。位置消息(Location/Vector)则包含经纬度、高度、速度、航向、时间戳、精度等级等。字段之间没有分隔符,全靠固定偏移量定位,所以偏移量算错一个字节,后面全乱。
这里有个容易忽略的点:经纬度不是直接存浮点数,而是按value / 10^7的定点数编码,纬度范围 -90 到 +90,经度 -180 到 +180。高度用的是value / 2 - 1000米,也就是分辨率 0.5 米,偏移 -1000 米。这些缩放因子如果记混,解析出来的坐标能偏出好几公里。
2.2 用 Python 解析一段 Basic ID 报文
下面这段代码是我常用的最小解析器,输入是去掉载体头之后的 RID 载荷字节,输出是结构化字典。它只处理 Basic ID 和 Location 两种消息,够用来验证抓包是否正确。
import struct # RID 消息类型枚举(部分) MSG_BASIC_ID = 0x0 MSG_LOCATION = 0x1 MSG_AUTH = 0x2 MSG_SELF_ID = 0x3 MSG_SYSTEM = 0x4 MSG_OPERATOR_ID = 0x5 def parse_rid_payload(payload: bytes) -> dict: """解析 RID 载荷,返回结构化字段""" if len(payload) < 2: raise ValueError("payload too short") msg_type = payload[0] version = payload[1] body = payload[2:] result = {"msg_type": msg_type, "version": version} if msg_type == MSG_BASIC_ID: # Basic ID: UA Type(1B) + ID Type(1B) + UAS ID(20B) if len(body) < 22: raise ValueError("basic id body too short") ua_type = body[0] id_type = body[1] uas_id = body[2:22].rstrip(b'\x00').decode('ascii', errors='ignore') result.update({ "ua_type": ua_type, "id_type": id_type, "uas_id": uas_id, }) elif msg_type == MSG_LOCATION: # Location: 按固定偏移解析 # 纬度(4B) 经度(4B) 高度(2B) 速度(2B) 航向(2B) 时间戳(2B) ... if len(body) < 16: raise ValueError("location body too short") lat_raw = struct.unpack('>i', body[0:4])[0] lon_raw = struct.unpack('>i', body[4:8])[0] alt_raw = struct.unpack('>H', body[8:10])[0] speed_raw = struct.unpack('>H', body[10:12])[0] heading_raw = struct.unpack('>H', body[12:14])[0] ts_raw = struct.unpack('>H', body[14:16])[0] result.update({ "latitude": lat_raw / 1e7, "longitude": lon_raw / 1e7, "altitude_m": alt_raw / 2.0 - 1000.0, "speed_mps": speed_raw / 2.0, # 常见缩放,具体看版本 "heading_deg": heading_raw / 2.0, "timestamp_s": ts_raw / 10.0, # 部分版本以 0.1s 为单位 }) return result # 示例:一段 Basic ID 载荷 raw = bytes.fromhex("001141424330303030303030303030303030303030303030") print(parse_rid_payload(raw))逻辑说明:先取消息类型和版本,再按类型分支。Basic ID 的 UAS ID 是 20 字节定长,尾部补零,用rstrip去掉再解码。Location 里所有多字节整数都是大端(网络字节序),用>i和>H解。参数说明:lat_raw / 1e7是定点转浮点,alt_raw / 2.0 - 1000.0是高度还原,速度和航向的缩放因子在不同版本里可能不同,实际对接时要以对应规范为准。这段代码跑通,说明你的抓包和字段偏移是对的。
2.3 消息类型与版本号的组合判断
消息类型只有 0 到 5 几个值,但版本号会影响消息体的长度和字段含义。我见过有人拿旧版解析器去读新版报文,结果时间戳字段错位,解析出来的时间差了十几秒。稳妥做法是:先读版本号,再查该版本对应的字段表,不要假设所有版本结构一致。如果版本号不在已知范围内,直接丢弃并记录,不要硬解。
3. 两套主流标准的结构差异:ASTM 与 ASD-STAN 对比
3.1 消息定义层面的异同
目前业界主要参考两套规范:一套是 ASTM 的 F3411 系列,另一套是 ASD-STAN 的对应标准。两者在消息类型划分上大体一致,都包含 Basic ID、Location、Auth、Self ID、System、Operator ID 这几类,但字段的排列顺序、缩放因子、可选字段的有无存在差异。比如 Location 消息里,ASTM 把速度放在高度之后,而另一套标准在某些版本里把垂直速度单独拆出来。如果你同时对接两类设备,解析器必须按标准分支,不能一套代码通吃。
我做对比时习惯列一张字段映射表,把两套标准的同一语义字段放在同一行,标注偏移量和缩放因子。这样在写解析器时,只需要在字段读取处加一个标准判断,不用重写整个解析逻辑。
| 字段语义 | ASTM 偏移 | ASTM 缩放 | ASD-STAN 偏移 | ASD-STAN 缩放 |
|---|---|---|---|---|
| 纬度 | 0-3 | /1e7 | 0-3 | /1e7 |
| 经度 | 4-7 | /1e7 | 4-7 | /1e7 |
| 高度 | 8-9 | /2-1000 | 8-9 | /2-1000 |
| 水平速度 | 10-11 | /2 | 10-11 | /2 |
| 垂直速度 | 无独立字段 | — | 12-13 | /2 |
| 航向 | 12-13 | /2 | 14-15 | /2 |
这张表不是官方文档的复制,而是我在实际对接中整理出来的常用映射,具体版本仍以对应规范为准。有了它,解析器里只需要一个standard参数,就能切换偏移量。
3.2 载体差异对解析的影响
BLE、Wi-Fi Beacon、Wi-Fi NAN 三种载体在载荷外面包的头不一样。BLE 扩展广播有自己的 AD Structure,Wi-Fi Beacon 有 Vendor Specific IE,Wi-Fi NAN 有服务发现帧。解析时第一步永远是剥掉载体头,拿到 RID 载荷,再走上面的解析逻辑。我一般把载体解析和载荷解析分成两个模块,载体模块只负责提取载荷,载荷模块只认字节,这样换载体时不用动核心解析代码。
def extract_ble_payload(ad_data: bytes) -> bytes: """从 BLE AD Structure 中提取 RID 载荷""" i = 0 while i < len(ad_data): length = ad_data[i] if length == 0: break ad_type = ad_data[i + 1] ad_value = ad_data[i + 2: i + 1 + length] # 0x16 是 Service Data,RID 常用此类型 if ad_type == 0x16: # 前两字节是 Service UUID,后面才是 RID 载荷 return ad_value[2:] i += 1 + length raise ValueError("no RID payload found")逻辑说明:BLE 的 AD Structure 是“长度 + 类型 + 值”的 TLV 序列,循环遍历找到 Service Data 类型,跳过两字节 UUID 后返回剩余部分。参数说明:0x16是蓝牙规范里 Service Data 的 AD Type,RID 的 Service UUID 因标准而异,这里只做示例,实际要按你对接的标准替换判断条件。
3.3 标准选择对落地的影响
如果你做的是监管接收端,通常要同时兼容两套标准,因为市面上设备可能来自不同阵营。如果做的是飞控固件,只需要按目标市场选择对应标准即可。我的建议是:解析层做成可配置的,标准差异集中在字段映射表里,不要散落在代码各处。这样新增一套标准时,只加一张表,不改逻辑。
4. 避坑与排查:RID 解析中最容易翻车的五个点
4.1 字节序搞反,坐标飞到南极
现象:解析出来的纬度是负数且数值巨大,或者经度完全不对。原因:RID 多字节整数是大端序,有人习惯小端直接struct.unpack('<i', ...)。解决:统一用>前缀,并在代码里加断言,纬度绝对值不超过 90,经度不超过 180,超出就报警。
4.2 版本号没判断,时间戳错位
现象:时间戳解析出来是几十秒甚至几百秒,和实际飞行时间对不上。原因:不同版本的时间戳单位和偏移不同,旧解析器读新报文。解决:先读版本号,按版本查字段表,未知版本直接丢弃并记录原始字节,方便事后分析。
4.3 载体头没剥干净,消息类型读错
现象:消息类型解析出来是 0x16 或 0xAA 这种不该出现的值。原因:把 BLE 的 AD Type 或 Wi-Fi 的 Vendor IE 头当成了 RID 载荷。解决:在载体解析模块里加校验,确认 Service UUID 匹配后再返回载荷,不匹配就继续遍历。
4.4 UAS ID 解码出乱码
现象:Basic ID 里的 UAS ID 打印出来是一串问号或不可见字符。原因:UAS ID 是 ASCII 定长字段,尾部补零,直接decode会把零字节也解出来。解决:先rstrip(b'\x00')再解码,并指定errors='ignore',同时检查长度是否符合预期。
4.5 缩放因子记混,高度差出上千米
现象:高度解析出来是负几百米,或者比实际高出一千多米。原因:高度公式是raw / 2 - 1000,有人记成raw / 2或raw - 1000。解决:把缩放因子写成常量并加注释,单元测试里用已知报文验证,比如原始值 2000 对应高度 0 米。
5. 进阶技巧:用已知报文做回归验证与标准切换
5.1 构造最小测试集
我习惯在项目里维护一个小型测试集,每条记录包含原始十六进制载荷、标准类型、期望解析结果。每次改解析器,先跑这个测试集,确保没有回归。测试集不需要多,覆盖 Basic ID、Location、System 三种消息,每套标准各两条即可。
import pytest TEST_CASES = [ { "name": "basic_id_astm", "standard": "astm", "payload": "001141424330303030303030303030303030303030303030", "expect": {"msg_type": 0, "version": 1, "uas_id": "ABC00000000000000000"}, }, { "name": "location_astm", "standard": "astm", "payload": "0101" + "0FFFFFFF" + "0FFFFFFF" + "0FA0" + "0064" + "00B4" + "0064", "expect": {"msg_type": 1, "version": 1, "latitude": pytest.approx(26.8435455, rel=1e-3)}, }, ] @pytest.mark.parametrize("case", TEST_CASES, ids=[c["name"] for c in TEST_CASES]) def test_parse(case): result = parse_rid_payload(bytes.fromhex(case["payload"])) for k, v in case["expect"].items(): assert result[k] == v逻辑说明:用参数化测试把每条用例独立跑,失败时能直接定位到哪条报文。参数说明:pytest.approx用于浮点比较,避免精度问题导致误报。测试集里的期望值是我根据字段定义手算的,不是从真实设备抄的,这样不涉及任何具体项目信息。
5.2 标准切换的配置化写法
把标准差异抽成配置字典,解析函数根据配置读字段。这样新增标准时只加配置,不改函数体。
FIELD_MAP = { "astm": { "location": { "lat": (0, 4, ">i", 1e7), "lon": (4, 8, ">i", 1e7), "alt": (8, 10, ">H", 2.0, -1000.0), "speed": (10, 12, ">H", 2.0), "heading": (12, 14, ">H", 2.0), } }, "asd": { "location": { "lat": (0, 4, ">i", 1e7), "lon": (4, 8, ">i", 1e7), "alt": (8, 10, ">H", 2.0, -1000.0), "speed": (10, 12, ">H", 2.0), "v_speed": (12, 14, ">H", 2.0), "heading": (14, 16, ">H", 2.0), } }, }逻辑说明:每个字段用元组描述偏移、字节序、缩放和偏移量,解析时统一循环处理。参数说明:元组最后两个值是可选的缩放和偏移,没有就不传。这样写的好处是,标准差异一目了然,排查时直接看配置表就知道哪个字段对不上。
5.3 抓包验证的实操习惯
我一般先用手机或开发板抓一段真实广播,存成十六进制文本,再用解析器跑一遍,把结果和肉眼观察到的飞行状态对比。如果坐标、高度、速度都对得上,说明解析链路没问题。如果对不上,先查载体头,再查版本号,最后查缩放因子。这个顺序能解决九成以上的解析异常。做 RID 解析,最怕的不是协议复杂,而是拿到报文就急着写代码,不先确认字节边界。我自己的习惯是,每接一套新设备,先花十分钟把原始字节按字段表手工拆一遍,确认无误再写解析器。这个笨办法帮我省下了大量返工时间。希望帮到你。
本文还有配套的精品资源,点击获取