Jelajahi Sumber

Add MQTT MAVLink bridge with dialect submodule

LiuYang 3 minggu lalu
melakukan
89b33e5557

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+.venv/
+__pycache__/
+*.py[cod]
+*.egg-info/
+.pytest_cache/
+config.local.json
+*.pdf

+ 4 - 0
.gitmodules

@@ -0,0 +1,4 @@
+[submodule "msg_definitions"]
+	path = msg_definitions
+	url = http://vk-fly.com:10880/VKFLY_INDUSTRY/VK_Mavlink.git
+	branch = main

+ 51 - 0
README.md

@@ -0,0 +1,51 @@
+# 摩天云 MQTT–MAVLink 串口桥
+
+使用子模块 `msg_definitions/msg_definitions/VKFly.xml` 生成的 MAVLink 2 方言解析串口数据(也接受 MAVLink 1 通用帧),按平台 Base JSON 协议上报核心遥测;不执行文档中的飞行业务指令。现有原始字节块透传保持不变:飞控串口读到的每个字节块转换为大写 HEX,包装为 `DataType=526` 发布到 `/PUBLISH/<SystemID>`;订阅 `/OBTAIN/<SystemID>`,把同类型消息的 HEX `Data` 解码后原样写入串口。原始读取块不等于 MAVLink 帧边界。
+
+## 结构化上报
+
+均发布到 `/RECEIVE/<SystemID>`,使用 `DataType`、`UAVType`、`Data`、`SystemID`、带时区的 `TimeStamp` 外壳:
+
+| DataType | 来源 | 行为 |
+| --- | --- | --- |
+| 258 飞行状态 | HEARTBEAT、ATTITUDE、GLOBAL_POSITION_INT、VFR_HUD、GPS_RAW_INT、HOME_POSITION | 心跳有效时每秒合并一次;未知字段不发送 |
+| 257 电池 | 仅 VK_BMS_STATUS | 有新值时至多每秒一次;`bat_id=0/1` 分别映射左右电池 |
+| 260 提示 | STATUSTEXT | 收到就上报,保留原始严重级别和 UTF-8 文本 |
+| 274 飞行状态 | HEARTBEAT 解锁标志 | 首次获知及改变时上报布尔值;**true=解锁、false=上锁,并非实际离地/着陆** |
+
+只解析首个有效飞控 HEARTBEAT 对应的 system/component ID。串口重连重选来源并清空缓存;心跳超过 5 秒暂停 #258,其他来源字段超过 5 秒自动剔除。只被动接收,不请求飞控遥测;如缺少字段,请先确认飞控串口是否广播对应消息。缺失消息类型可用 `--debug` 查看。`#257` 在双电池时不假定并联/串联或容量一致,不填写推算的总电压/电量。
+
+## 安装和运行
+
+```bash
+python3 -m venv .venv
+.venv/bin/python -m pip install -e .
+cp config.example.json config.local.json
+# 修改 config.local.json:真实 SystemID、平台分配的 UAVType、串口及 MQTT 参数
+export MOTIAN_MQTT_PASSWORD='由部署环境提供的密码'
+.venv/bin/motian-mavlink-bridge --config config.local.json
+```
+
+`config.local.json` 已被忽略,不要提交凭据。无账号时将 `mqtt_username` 和 `mqtt_password_env` 均设为 `null`。`mqtt_tls=true` 使用系统可信 CA 或 `mqtt_ca_file`;不要关闭证书校验。`serial_port` 可以是 `/dev/tty*` 或 pyserial 支持的 URL。配置文件中的 `uav_type: 0` 只是示例,正式值须由平台分配。
+
+`publish_raw_mavlink` 默认为 `true`,设置为 `false` 可停止 `/PUBLISH/` 原始上行,**下行 #526 不受影响**。下行 JSON 必须有 `DataType`(**没有尾随空格**)、`UAVType`、`Data`、`SystemID`、`TimeStamp`,设备标识须与本机配置一致。`Data` 必须是非空、偶数长度的 HEX 字符串,最多 `max_command_bytes` 字节。其他 `DataType` 默认只记日志并丢弃;不会伪造 ACK。所有 retained 下行均丢弃,MQTT 使用 QoS 0、不保留发布消息。MQTT 或串口断线期间不主动缓存数据;重连后不会主动重放旧指令。
+
+扩展飞控业务时,通过 `Bridge.register_handler(data_type, handler)` 注册非 526 命令回调。回调收到 `BaseMessage` 和桥实例,可调用 `bridge.publish_report(data_type, data)` 向 `/RECEIVE/<SystemID>` 上报。透传上行通过同一方法的 `passthrough=True` 发布到 `/PUBLISH/<SystemID>`。回调应自行处理飞控交互和业务 ACK,不要把未实现的命令视为成功。
+
+## 本地检查
+
+```bash
+.venv/bin/python -m unittest discover -s tests -v
+```
+
+方言源文件由 `msg_definitions` Git 子模块提供,实际 XML 位于 `msg_definitions/msg_definitions/`。若子模块中的 XML 改动,需要用项目环境中的 `pymavlink` 重新生成提交的 Python 文件(原文件不要手工修改):
+
+```bash
+.venv/bin/python -m pymavlink.tools.mavgen --lang Python --wire-protocol 2.0 \
+    --output motian_bridge/dialects/v20/VKFly.py msg_definitions/msg_definitions/VKFly.xml
+.venv/bin/python -m unittest discover -s tests -v
+```
+
+当前生成文件使用 pymavlink **2.4.50**;生成时会显示 XML bitmask 枚举的已有警告,但会完成生成。重新生成后应复测真实 MAVLink 帧,以确认设备 XML 版本和校验码兼容。
+
+协议仅定义 `/PUBLISH/无人机id` 为“透传包”,没有提供上行载荷示例。本实现按 **Base JSON + DataType 526 + HEX Data** 约定,正式联调前需平台确认。`TimeStamp` 为本机时区 ISO-8601 时间;应确保设备时间正确。MQTT QoS 0 降低重放风险,但不是严格端到端交付保证。此服务不做飞行安全仲裁,正式部署应限制 Broker 访问权限。

+ 20 - 0
config.example.json

@@ -0,0 +1,20 @@
+{
+  "system_id": "UAV000001",
+  "uav_type": 0,
+  "serial_port": "/dev/ttyUSB0",
+  "serial_baudrate": 57600,
+  "mqtt_host": "broker.example.com",
+  "mqtt_port": 8883,
+  "mqtt_client_id": "",
+  "mqtt_username": "replace-with-platform-username",
+  "mqtt_password_env": "MOTIAN_MQTT_PASSWORD",
+  "mqtt_tls": true,
+  "mqtt_ca_file": null,
+  "mqtt_cert_file": null,
+  "mqtt_key_file": null,
+  "mqtt_keepalive": 60,
+  "max_chunk_bytes": 1024,
+  "max_command_bytes": 4096,
+  "reconnect_seconds": 2.0,
+  "publish_raw_mavlink": true
+}

+ 27 - 0
doc/实施方案.md

@@ -0,0 +1,27 @@
+# MQTT–MAVLink 串口透传实施方案
+
+## 概要
+
+新建 Linux/Python 服务。本期只实现原始字节块透传,不解析 MAVLink 帧,也不转换起飞、航线等业务指令。
+
+## 实现
+
+- 订阅 `/OBTAIN/<SystemID>`,仅将 `DataType=526`、设备标识匹配、`Data` 为合法 HEX 的 Base JSON 解码后写入串口;拒绝 retained 消息。
+- 串口读到的字节块逐块转为 HEX,作为 `DataType=526` 的 Base JSON 发布到 `/PUBLISH/<SystemID>`;`UAVType`、`SystemID` 来自配置,`TimeStamp` 使用带时区时间。使用正确字段名 `DataType`,不沿用文档示例的尾随空格。
+- 串口参数、MQTT 地址、认证、TLS 和设备标识由部署配置提供。MQTT 使用 QoS 0、不保留消息;断线重连但不主动缓存或重放旧数据。
+- 提供按 `DataType` 注册回调和通用上报 API;未实现的非 526 指令只记录日志并丢弃,不发送未定义的应答。
+
+## 测试
+
+使用模拟 MQTT 和串口验证双向字节一致性、JSON Base 字段、非法数据及 retained 消息拒绝、断线丢弃、重连恢复和扩展回调分派。取得实际设备及平台参数后进行现场联调。
+
+## 前提
+
+协议未提供 `/PUBLISH/<SystemID>` 的载荷样例;上行暂定 **Base JSON + 526 + HEX Data**,正式接入前须平台确认。`UAVType` 正式值由平台分配,不猜测。
+
+## 第二阶段:VKFly MAVLink 状态上报(2026-09-29)
+
+- 使用 `msg_definitions` Git 子模块中的 `msg_definitions/VKFly.xml` 及其包含文件生成 MAVLink 2 Python 方言,解析串口完整帧;只被动接收并自动绑定首个飞控 HEARTBEAT,串口重连时重置。
+- `/RECEIVE/<SystemID>` 增加 #258 飞行状态(1 秒合并)、#257 厂商 BMS 电池(有更新时至多每秒一次)、#260 STATUSTEXT 提示、#274 解锁/上锁布尔事件。只上报有效的已知字段;心跳或其他来源超过 5 秒即停止使用旧数据。
+- 保持下行 #526 不变;原始串口字节上行 #526 保留,增加 `publish_raw_mavlink` 配置开关,默认为开启。
+- 使用生成的真实 MAVLink 帧验证分块、CRC、来源、单位、超时及双电池;平台对部分字段的接受情况仍需实际联调。#274 的“飞行”在此阶段按解锁解释,不代表物理离地。

+ 1658 - 0
doc/摩天云平台-无人机接口细化规范_Markdown版.md

@@ -0,0 +1,1658 @@
+# 摩天云平台-无人机接口细化规范
+
+> Markdown 转换版:根据同目录 PDF《摩天云平台-无人机接口细化规范(对外)v20260918.pdf》的可提取文本整理。
+> 转换日期:2026-09-28。为便于阅读添加了 Markdown 标题和代码围栏;原文中的字段、编号、示例及可能存在的拼写/内容问题均尽量保留,使用前请以平台方确认结果为准。
+
+摩天云平台-无人机接口细化规范
+
+发行           核准               审核   制定
+
+会签
+
+发布日期:        实施日期:
+
+## 文件修订履历表
+
+修订类别           抽   总
+版
+修订日期                        换   页            修订内容简要                   修订者
+新    删   修
+本
+页   数
+增    除   订
+创建、App 设计、协议出版分
+2026-07-08                                                            龚学平
+析
+1、 4.17 与 4.22 追加 4G 失
+联动作、按键开关、RTF 开关          龚学平
+2026-09-18
+2、新增 4.39                许向往
+3、新增 4.40
+
+## 目录
+
+1 MQTT 主题规范 ..................................................................................................................... 5
+2 通讯 Base 数据结构体 .......................................................................................................... 5
+3 Base 结构体-枚举类型说明 ................................................................................................... 5
+3.1 MqttDataType ....................................................................................................................... 5
+3.2 UAVType .............................................................................................................................. 6
+4 Base 结构体(飞机主动上报)-Data 详细说明 ........................................................................6
+4.1 无人机电池信息(#257) .....................................................................................................6
+4.2 飞机飞行状态信息(#258) ...................................................................................................8
+4.3 无人机提示信息(#260) .....................................................................................................13
+4.4 飞机上传下载的航线(#261) ............................................................................................ 13
+4.5 电子围栏,航线上传进度信息(#264) ............................................................................ 16
+4.6 无人机执行进度(#265) .....................................................................................................16
+4.7 异常标识(#269) .................................................................................................................17
+4.8 应答消息提示包(#270) .....................................................................................................18
+4.9 避障信息(#272) ................................................................................................................ 18
+4.10 无人机飞行状态(#274) ...................................................................................................19
+4.11 健康管理模块(#275) .......................................................................................................19
+4.12 健康管理预警信息(#276) .............................................................................................. 22
+4.13 精准降落视觉识别事件通知(#279) .............................................................................. 23
+4.14 视频开始或停止推流结果反馈(#280) .......................................................................... 23
+4.15 下视觉图像(#301) ...........................................................................................................23
+4.16 固件版本上报(#302) .......................................................................................................23
+4.17 无人机设备底层参数配置(#303) .................................................................................. 24
+4.18 外挂装置主动上报(#304) .............................................................................................. 25
+4.19 接入成功(#2456) .............................................................................................................26
+4.20 断开连接(#2457) .............................................................................................................26
+Base 结构体(飞机控制)-Data 详细说明 .................................................................................26
+4.21 获取飞机底层参数(#281) .............................................................................................. 26
+4.22 配置飞机底层参数(#282) .............................................................................................. 26
+4.23 云台 can 包透传(#514) ................................................................................................... 27
+4.24 MAVLINK 数据包透传(#526) ........................................................................................ 27
+4.25 改变无人机模式(#2049) ................................................................................................28
+4.26 无人机解锁(#2050) .........................................................................................................28
+4.27 无人机上传航线(#2051) ................................................................................................ 28
+4.28 无人机下载航线(#2052) ................................................................................................ 31
+4.29 无人机起飞(#2054) .........................................................................................................31
+4.30 无人机降落(#2055) .........................................................................................................31
+4.31 无人机暂停(#2056) .........................................................................................................32
+4.32 无人机继续任务(#2057) ................................................................................................ 32
+4.33 无人机结束任务(#2058) ................................................................................................ 32
+4.34 取消航线任务(#2071) .....................................................................................................32
+4.35 下置摄像头图传(#2066) ................................................................................................33
+
+4.36 紧急迫降(#2069) .............................................................................................................33
+4.37 指点飞行(#2070) .............................................................................................................33
+4.38 手动控制飞行(#2072) .....................................................................................................34
+4.39 高空紧急停桨(#2073)-停桨后开伞 ............................................................................ 34
+4.40 地面紧急停桨(#2074)-停桨后不开伞 ........................................................................35
+4.41 手动接管(#2075) .............................................................................................................35
+4.42 外挂装置控制(#2099) .....................................................................................................35
+4.43 飞控日志下载(#2100) .....................................................................................................36
+4.44 OTA 升级(#2101) .............................................................................................................36
+
+## 1 MQTT 主题规范
+
+- /RECEIVE/无人机 Id       --无人机基础信息主动上报
+- /OBTAIN/无人机 Id         --无人机控制
+- /PUBLISH/无人机 id        --无人机透传包
+- /RECEIVE/停机坪 Id       --停机坪数据
+- /OBTAIN/停机坪 Id)       --停机坪控制
+
+## 2 通讯 Base 数据结构体
+
+属性名 (Property)                类型 (Type)                         说明
+
+数据类型(枚举,标识 Data 中承载的具体数
+DataType                MqttDataType
+据种类)
+
+无人机类型(枚举,标识数据来源的无人机型
+UAVType                 int
+号/类别)
+
+数据内容,具体类型由使用方在实例化时指
+Data                    T(泛型)
+定
+
+SystemID                string                        系统 ID,标识数据所属的无人机/系统
+
+发送时间,默认值为对象创建时的当前时间
+TimeStamp               DateTime
+(DateTime.Now)
+
+## 3 Base 结构体-枚举类型说明
+
+### 3.1 MqttDataType
+
+序号                     枚举名                         十六进制值       十进制值         说明
+基础消息 无人机主动上报
+1         UAV_REC_BATTERY                           0x101       257    无人机电池信息
+2         UAV_REC_FLGHTDATA                         0x102       258    飞机飞行状态信息
+3         UAV_STATUS_TEXT                           0x104       260    无人机提示信息
+4         UAV_RECEIVE_WAYPOINT                      0x105       261    飞机上传下载的航线
+5         UAV_RECEIVE_UPLOAD                        0x108       264    电子围栏,航线上传进
+度信息
+6         UAV_RECEIVE_EXECUTE                       0x109       265    无人机执行进度
+7         UAV_RECEIVE_FAILURE_FLAGS                 0x10D       269    异常标识
+8         UAV_RECEIVE_ACK                           0x10E       270    应答消息提示包
+9         UAV_RECEIVE_OBS                           0x110       272    避障信息
+10        UAV_RECEIVE_FLYING                        0x112       274    无人机飞行状态
+11        UAV_RECEIVE_HEALTH                        0x113       275    健康管理模块
+12        UAV_RECEIVE_WARNING                       0x114       276    健康管理预警信息
+13        UAV_RECEIVE_LANDING                       0x117       279    精 准 降 落 视 觉 识 别事
+件通知
+
+14     UAV_RECEIVE_CAMERARESULT              0x118    280    视 频 开 始 或 停 止 推流
+结果反馈
+15     UAV_RECEIVE_DOWN_CAMMERA              0x12D    301    下 视 觉 图 像
+(stereo_image)
+16     UAV_RECEIVE_VERSION                   0x12E    302    固件版本上报
+17     UAV_RECEIVE_PARAMS                    0x12F    303    无 人 机 设 备 底 层 参数
+配置
+18     UAV_RECEIVE_ LOADINGEQUIP             0x130    304    外挂装置主动上报
+19     UAV_CONNECT                           0x998    2456   接入成功
+20     UAV_DISCONNECT                        0x999    2457   断开连接
+无人机控制,调度方控制
+21     UAV_GETPARAMS                         0x119    281    获取飞机底层参数
+22     UAV_SETPARAM                          0x120    282    配置飞机底层参数
+23     UAV_CANSEND                           0x202    514    云台 can 包透传
+24     UAV_MAVLINKSEND                       0x20E    526    MAVLINK 数据包透传
+25     UAV_CHANGEMODEL                       0x801    2049   改变无人机模式
+26     UAV_CHANGELOCK                        0x802    2050   无人机解锁
+27     UAV_UPLOADAIRLINE                     0x803    2051   无人机上传航线
+28     UAV_DOWNLOADAIRLINE                   0x804    2052   无人机下载航线
+29     UAV_FLYING                            0x806    2054   无人机起飞
+30     UAV_LANDING                           0x807    2055   无人机降落
+31     UAV_PAUSE                             0x808    2056   无人机暂停
+32     UAV_CONTINUE                          0x809    2057   无人机继续任务
+33     UAV_FINISH                            0x810    2058   无人机结束任务
+34     UAV_DOWNCAMERA                        0x812    2066   下置摄像头图传
+35     UAV_EMERGENCYLAND                     0x815    2069   紧急迫降
+36     UAV_POINTEFLY                         0x816    2070   指点飞行
+37     UAV_TASKCANCEL                        0x817    2071   取消航线任务
+38     UAV_CONTROLFLY                        0x818    2072   手动控制飞行
+39     UAV_EMERGENCYSTOPPROPELLER            0x819    2073   紧急停桨
+40     UAV_HANDLETAKEOVER                    0x81A    2074   手动接管
+41     UAV_LOADINGEQUIPCONTROL               0x833    2099   外挂装置控制
+42     UAV_UPLOADLOGS                        0x834    2100   飞控日志下载
+
+43     UAV_OTA_UPGRADE                       0x835    2101   OTA 升级
+### 3.2 UAVType
+
+需要向摩天提出申请,由摩天提供
+
+## 4 Base 结构体(飞机主动上报)-Data 详细说明
+
+### 4.1 无人机电池信息(#257)
+
+本类用于描述无人机通信回传的电池状态数据,涵盖单体/左右双电池的电压、电
+流、剩余电量、温度、循环次数及飞行/充电时间等信息。
+属性名 (C#)          数据类型        Description 标签           中文说明
+
+SystemID                        string   -        系统 ID
+BatterType                      int               电池类型
+BatterTypeName                  string   -        电池类型名称
+Battery_Status                  int               电池状态
+Battery_Voltage                 double            电压
+Battery_VoltageLeft             double   -        左电池电压
+Battery_VoltageRight            double   -        右电池电压
+Battery_Current                 double            电流
+Battery_CurrentLeft             double   -        左电池电流
+Battery_CurrentRight            double   -        右电池电流
+Battery_Remaining               double            剩余电量
+Battery_RemainingLeft           double   -        左电池剩余电量
+Battery_RemainingRight          double   -        右电池剩余电量
+Battery_Temperature             double            电池温度
+lowVoltWarnValue                float             低电压报警阈值(V)
+statusType                      int               电池状态:0 无效值,1 开机,2
+充电中,3 关机
+cycleIndex                      int               充电循环次数
+usingCapacity                   int               使用放电容量
+remainingCapacity               int               剩余容量
+fullCapacity                    int               满电容量
+timeFlying                      int               使用飞行时间
+timeRemaining                   int               剩余飞行时间
+chargeTimeRemaining             int               剩余充电时间
+flags                           int               电池标志码
+uid                             string            电池二维码字符串(电池序列号)
+version                         string            电池版本号
+PowerConsumptionRateLeft        double   -        左电池电量衰减速率
+PowerConsumptionRateRight   double       -        右电池电量衰减速率
+Josn 示例
+```json
+{
+"SystemID": "UAV-001",
+"BatterType": 1,
+"BatterTypeName": "智能电池",
+"Battery_Status": 1,
+"Battery_Voltage": 22.8,
+"Battery_VoltageLeft": 22.7,
+"Battery_VoltageRight": 22.9,
+"Battery_Current": 3.5,
+"Battery_CurrentLeft": 1.7,
+"Battery_CurrentRight": 1.8,
+"Battery_Remaining": 76.0,
+"Battery_RemainingLeft": 75.5,
+"Battery_RemainingRight": 76.5,
+"Battery_Temperature": 28.3,
+"lowVoltWarnValue": 19.5,
+"statusType": 1,
+"cycleIndex": 42,
+"usingCapacity": 1520,
+"remainingCapacity": 4480,
+"fullCapacity": 6000,
+"timeFlying": 1830,
+"timeRemaining": 1260,
+"chargeTimeRemaining": 0,
+"flags": 0,
+"uid": "SN20260708001",
+"version": "V1.3.2",
+"PowerConsumptionRateLeft": 0.021,
+"PowerConsumptionRateRight": 0.019
+}
+```
+
+### 4.2 飞机飞行状态信息(#258)
+
+字段名                        类型                        说明
+OrderID                       string             飞行编号
+SystemID                      string             系统 ID
+Armed                         bool               是否锁定中
+Pitch                         double             俯仰角
+Roll                          double             横滚角
+Yaw                           double             偏航值
+DroneAutopilot                int                飞控类型
+UavType                       int                机型类型
+FlightMode                    string             飞控模式
+FlyTime                       long               飞行时间(当前任务)
+FlyDistance                   float              当前飞行里程
+DistanceToHome                float              水平距离 home 的位置
+SpaceDistToHome               float              距离 home 的位置空间距离
+AirSpeed                      float              hud 空速
+GroundSpeed                   float              hud 地速
+ClimbRate                     float              垂直爬升速度
+Altitude                      float              高度
+Latitude                      double             纬度
+Longitude                     double             经度
+Temperature                   double             温度(默认-1)
+Windspeed                     double             风速(默认-1)
+Winddirection                 string             风向(默认"-")
+Is_day                        string             是否白天(默认"-")
+Weathercode                   int                天气代码(默认-1)
+
+WeathercodeName                string              天气代码名称(默认"-")
+WindLevel                      int                 风力等级
+WindLevelName                  string              风力等级名称
+HomeAltitude                   float               飞机原点坐标高度
+HomeLatitude                   double              飞机原点坐标纬度
+HomeLongitude                  double              飞机原点坐标经度
+Gps                            GPSModel            GPS 信息
+Rtk                            GPSModel            RTK 信息
+Signal                         StationSignal       基站信号信息
+IsAccelNeedCal                 bool                加速度计校准状态,true 未校准,false 已校准
+IsFailsafeEnabled              bool                安全开关,true 打开
+IsFlying                       bool                true 表示飞行中(电机解锁后即为飞行中)
+IsGyroNeedCal                  bool                陀螺仪校准状态,true 未校准,false 已校准
+IsHomeLocationSet              bool                home 点是否已定位
+IsHorNeedCal                   bool                水平校准状态,true 未校准,false 已校准
+IsMagNeedCal                   bool                指南针校准状态,true 未校准,false 已校准
+IsMotorsOn                     bool                true 表示电机打开(需安全开关打开才可解锁)
+IsRtkConnected                 bool                RTK 数据 RTCM 是否连接
+IsRtkEnable                    bool                RTK 是否启用
+IsRcConnecting                 bool                遥控器是否连接
+PreflightCheck                 bool                起飞前检测,true 通过,false 自检未通过
+RcOnline                       int                 0 内外场遥控器均不在线,1 外场在线,2 内场在线,
+3 内外场都在线
+VelocityX                      float               NED 坐标系下 x 方向速度(米/秒)
+VelocityY                      float               NED 坐标系下 y 方向速度(米/秒)
+VelocityZ                      float               NED 坐标系下 z 方向速度(米/秒)
+Parachute                      int                 降落伞信息
+ObsState                       int                 避障雷达在线状态,1 在线,0 离线
+Link                           List<FlightLink>    公专网 TCP 连接信息
+RouteDistance                  double              航线距离
+EstimatedFlightDistance        double              预计可飞距离
+RemainingDistance  double      剩余航线里程
+子结构:GPSModel(GPS / RTK 通用结构)
+字段名                类型                                说明
+AbsoluteAlt           float             绝对高度(米)
+FixType               string            GPS 锁定类型
+LevelDivisor          float             水平因子
+RelativeAlt           float             相对高度(米)
+SatelliteCount        int               搜星数,卫星数越大 GPS 信号越强
+VerticalDivisor       float             垂直因子
+Type                  int               19=RTK,15=GPS,0=默认(固件未更新)
+SatelliteMode         int               卫星定位模式:1 纯北斗,0 GNSS(通常为 GPS)
+
+IsMainSensor             bool             true 主传感器,false 备用传感器
+子结构:StationSignal(基站信号信息)
+字段名                              类型                          说明
+sqe14                    double                           1.4G 基站信号质量值(RSRP,dBm)
+sqe14_quality            int                              1.4G 信号质量等级:1 强 2 好 3 一
+般 4 弱 5 差 6 无信号(-1 表示断
+开)
+sqe45                    double                           4/5G 基站信号质量值(RSRP,dBm)
+sqe45_quality            int                              4/5G 信号质量等级:1 强 2 好 3 一
+般 4 弱 5 差(-1 表示断开)
+type45                   string                           公网信号类型:"4G"或"5G"
+signal14                 List<Dictionary<string,strin     1.4G 基站信号详细信息
+g>>
+signal45                 List<Dictionary<string,strin     4/5G 基站信号详细信息
+g>>
+up14Link                 long                             1.4G 上行速率(字节/秒)
+down14Link               long                             1.4G 下行速率(字节/秒)
+up45Link                 long                             4/5G 上行速率(字节/秒)
+down45Link               long                             4/5G 下行速率(字节/秒)
+1.4G 信号质量对照表(sqe14 → sqe14_quality)
+信号范围 (dBm)               质量等级                          说明
+Rx ≤ -120                         6            无信号、覆盖较差,业务基本无法起呼
+-120 ≤ Rx < -115                  5            信号差、覆盖差,室外语音勉强起呼,掉话率高
+-115 ≤ Rx < -110                  4            信号弱、覆盖一般,室外可发起低速率数据业务
+-110 ≤ Rx < -105                  3            一般、覆盖较好,室外可获得中等速率数据业务
+-105 ≤ Rx < -100                  2            信号好、覆盖好,室外可获得高速率数据业务
+Rx > -100                         1            信号强,覆盖非常好
+-1                                -
+基站信号数据断开
+4/5G 信号质量对照表(sqe45 → sqe45_quality)
+信号范围 (dBm)                   质量等级     说明
+RSRP < -115                           5        信号差、覆盖差,室外语音勉强起呼,掉话率高
+-115 < Rx ≤ -105                      4        信号弱、覆盖一般,室外可获得低速率数据业务
+-105 < Rx ≤ -95                       3        一般、覆盖较好,室外可获得中等速率数据业务
+-95 < Rx ≤ -85                        2        信号好、覆盖好,室外可获得高速率数据业务
+RSRP ≥ -85                            1        信号强,覆盖非常好
+-1                                    -
+基站信号数据断开
+子结构:FlightLink(公专网 TCP 连接信息)
+字段名            类型                    说明
+Bitrate          float            链路比特率
+Josn 示例
+```json
+{
+"OrderID": "TASK-20260708-001",
+"SystemID": "1",
+"Armed": true,
+"Pitch": 1.2,
+"Roll": -0.5,
+"Yaw": 87.3,
+"DroneAutopilot": 3,
+"UavType": 2,
+"FlightMode": "AUTO",
+"FlyTime": 365,
+"FlyDistance": 1240.5,
+"DistanceToHome": 620.3,
+"SpaceDistToHome": 635.8,
+"AirSpeed": 12.4,
+"GroundSpeed": 13.1,
+"ClimbRate": 0.8,
+"Altitude": 120.5,
+"Latitude": 31.2304,
+"Longitude": 121.4737,
+"Temperature": 26.5,
+"Windspeed": 3.2,
+"Winddirection": "东南",
+"Is_day": "1",
+"Weathercode": 1,
+"WeathercodeName": "晴",
+"WindLevel": 2,
+"WindLevelName": "轻风",
+"HomeAltitude": 15.0,
+"HomeLatitude": 31.2280,
+"HomeLongitude": 121.4700,
+"Gps": {
+"AbsoluteAlt": 135.5,
+"FixType": "3D_FIX",
+"LevelDivisor": 0.8,
+"RelativeAlt": 120.5,
+"SatelliteCount": 18,
+"VerticalDivisor": 1.1,
+"Type": 15,
+"SatelliteMode": 0,
+"IsMainSensor": true
+},
+"Rtk": {
+"AbsoluteAlt": 135.6,
+"FixType": "RTK_FIXED",
+"LevelDivisor": 0.3,
+"RelativeAlt": 120.6,
+"SatelliteCount": 22,
+"VerticalDivisor": 0.4,
+"Type": 19,
+"SatelliteMode": 0,
+"IsMainSensor": false
+},
+"Signal": {
+"sqe14": -98.5,
+"sqe14_quality": 2,
+"sqe45": -90.2,
+"sqe45_quality": 2,
+"type45": "5G",
+"signal14": [{"cellId": "1401", "rsrp": "-98"}],
+"signal45": [{"cellId": "4501", "rsrp": "-90"}],
+"up14Link": 12000,
+"down14Link": 45000,
+"up45Link": 80000,
+"down45Link": 250000
+},
+"IsAccelNeedCal": false,
+"IsFailsafeEnabled": true,
+"IsFlying": true,
+"IsGyroNeedCal": false,
+"IsHomeLocationSet": true,
+"IsHorNeedCal": false,
+"IsMagNeedCal": false,
+"IsMotorsOn": true,
+"IsRtkConnected": true,
+"IsRtkEnable": true,
+"IsRcConnecting": true,
+"PreflightCheck": true,
+"RcOnline": 3,
+"VelocityX": 5.2,
+"VelocityY": 1.1,
+"VelocityZ": -0.3,
+"Parachute": 0,
+"ObsState": 1,
+"Link": [
+{"Bitrate": 4500.0}
+],
+"RouteDistance": 5000.0,
+"EstimatedFlightDistance": 8000.0,
+"RemainingDistance": 3759.5
+}
+```
+
+### 4.3 无人机提示信息(#260)
+
+字段名            类型                  说明
+severity         int      消息等级
+msg              string   消息内容
+codeValue   string           故障值 / 故障码
+severity 消息等级参考
+数值           等级名称                                     说明
+0           EMERGENCY                 系统不可用,最高级别紧急故障
+1           ALERT                     需要立即处理
+2           CRITICAL                  严重故障
+3           ERROR                     错误
+4           WARNING                   警告,可能出现的问题
+5           NOTICE                    重要提示,非错误
+6           INFO                      一般提示信息
+7           DEBUG                     调试信息,用于开发排查
+示例数据(JSON)
+```json
+{
+"severity": 4,
+"msg": "电池电量低,请尽快返航",
+"codeValue": "BATTERY_LOW_001"
+}
+```
+
+### 4.4 飞机上传下载的航线(#261)
+
+上传或下载成功会返回无人机航线信息
+
+字段名                            类型                      说明
+
+AutoSpeed                            int                         航线自动速度
+FinishedAction                       string                      航线结束后飞机将采取的行动
+FinishedActionVal                    string                      航线结束动作对应的具体值(无
+Description 特性)
+IsExitMissionOnRCSignalLostEnabled   bool                        遥控器与飞机连接丢失时是否
+停止任务,默认 false
+MaxFlightSpeed                       float                       航线飞行最大速度,PX4 无人机
+默认最大 12m/s
+ReturnHeight                         float                       返航降落高度
+WayPoints                            List<WavePoint>             航点列表
+flightSortiesID                      string                      飞行架次号
+taskId                               string                      任务 ID
+rallyList (RallyList)                List<LocationCoordin        备降点信息列表
+ate>
+FinishedAction 类型种类
+FinishedAction                  说明              类型
+NO_ACTION                           悬停                   String
+GO_HOME                             返航                   String
+
+USING_INITIAL_DIRECTION               原地降落                      String
+PreLand                               精准降落                      String
+GO_FIRST_WAYPOINT                     返航后悬停                     String
+
+子结构:WavePoint(航点信息)
+字段名                    类型                                           说明
+Frame           int                     高度定位坐标类型:0=Global(WGS84) relative,相对于
+海拔;3=Global(WGS84),相对于 home 点高度
+Coordinate      LocationCoordinate      经纬度信息
+Action          WaypointActionType      该航点动作
+Speed           float                   该航点飞行速度
+子结构:WaypointAction 航点动作类型
+WaypointAction                说明                 类型
+actionType               航点动作类型         WaypointActionType
+param1                   参数值            Float
+param2                   参数值            Float
+param3                   参数值            Float
+param4                   参数值            Float
+子结构:WaypointActionTyp 航点动作
+WaypointActionType                                     说明                  类型
+STAY                             飞机悬停时间,以毫秒为单位                               Integer
+PERMMANENT_ STAY                 永久悬停                                        String
+START_TAKE_PHOTO                 开始拍摄照片                                      String
+START_RECORD                     开始录制视频                                      String
+STOP_RECORD                      停止录制视频                                      String
+ROTATE_AIRCRAFT                  旋转飞机的偏航,参数取值范围(-180~180)                    String
+GIMBAL_PITCH                     云台姿态控制(-90~30)                              String
+TRIGGER                          间距,间时拍照                                     String
+CAMERA_ZOOM                      相机变焦,参数为变焦倍数                                String
+子结构:LocationCoordinate(经纬度坐标)
+字段名                 类型                      说明
+Altitude                float        高度
+Latitude                double       纬度
+Longitude               double       经度
+示例数据(JSON)
+```json
+{
+"AutoSpeed": 8,
+"FinishedAction": "RTL",
+"FinishedActionVal": "",
+"IsExitMissionOnRCSignalLostEnabled": false,
+"MaxFlightSpeed": 12.0,
+"ReturnHeight": 60.0,
+"flightSortiesID": "SORTIE-20260708-003",
+"taskId": "TASK-20260708-001",
+"WayPoints": [
+{
+"Frame": 3,
+"Coordinate": {
+"Altitude": 80.0,
+"Latitude": 31.2304,
+"Longitude": 121.4737
+},
+"Action": {
+"type": "TAKEOFF",
+"hoverTimeSec": 0,
+"gimbalPitch": 0
+},
+"Speed": 6.0
+},
+{
+"Frame": 3,
+"Coordinate": {
+"Altitude": 100.0,
+"Latitude": 31.2318,
+"Longitude": 121.4755
+},
+"Action": {
+"type": "HOVER_AND_PHOTO",
+"hoverTimeSec": 5,
+"gimbalPitch": -30
+},
+"Speed": 8.0
+},
+{
+"Frame": 3,
+"Coordinate": {
+"Altitude": 100.0,
+"Latitude": 31.2335,
+"Longitude": 121.4770
+},
+"Action": {
+"type": "NONE",
+"hoverTimeSec": 0,
+"gimbalPitch": -45
+},
+"Speed": 8.0
+}
+],
+"RallyList": [
+{
+"Altitude": 50.0,
+"Latitude": 31.2295,
+"Longitude": 121.4720
+}
+]
+}
+```
+
+### 4.5 电子围栏,航线上传进度信息(#264)
+
+字段名                  类型                           说明
+UploadedWaypointIndex             int          当前已上传的航点序号(seq),从 0 开始
+TotalWaypointCount                int          总共需要上传的航点数量
+MissionType                       int          上传类型:0=航线(Mission),1=电子围栏(Fence)
+State                     string               当前上传状态,见下方状态表
+MissionType 上传类型对照表
+数值         类型名称                           说明
+0              Mission     航线任务
+1              Fence 电子围栏
+State 上传状态对照表
+状态值                                     说明
+UPLOAD_STARTING                   开始上传航线
+UPLOADING                         航线上传中
+UPLOAD_FINISH                     航线上传完成
+UPLOAD_TIMEOUT                    航线上传超时
+示例数据(JSON)
+```json
+{
+"UploadedWaypointIndex": 12,
+"TotalWaypointCount": 40,
+"MissionType": 0,
+"State": "UPLOADING"
+}
+```
+
+### 4.6 无人机执行进度(#265)
+
+无人机执行进度
+字段名                       类型                        说明
+TaskInfo                                string     航点动作执行索引 / 任务编号(无 Description 特
+性)
+Action                                  string     执行的动作,"NON"表示飞到航点位置
+ActionIndex                             int        当前执行的航点动作索引,从 0 开始
+isFinishAction (IsFinishAction)         bool       当前航点动作是否完成,默认 false
+WaypointIndex                           int        执行航点索引值,从 1 开始,默认 0
+IsFinish                                bool       是否完成,默认 false
+
+Progress                                 int      当前动作完成进度,默认 0
+TotalWaypointCount                       int      总航点数量(字段注释为"总完成进度",实际为总航
+点数),默认 0
+DistTraveled                             double   已行走距离,默认 0
+WPDist                                   double   距离下一航点的距离,默认 0
+示例数据(JSON)
+```json
+{
+"TaskInfo": "TASK-20260708-001",
+"Action": "NON",
+"ActionIndex": 0,
+"IsFinishAction": false,
+"WaypointIndex": 5,
+"IsFinish": false,
+"Progress": 42,
+"TotalWaypointCount": 12,
+"DistTraveled": 860.5,
+"WPDist": 35.2
+}
+```
+
+### 4.7 异常标识(#269)
+
+data                    类型                 说明
+1                                 int             t
+判断失败标识方法:
+值                              字段名称                            描述
+1            HL_FAILURE_FLAG_GPS                             GPS 故障。
+2            HL_FAILURE_FLAG_DIFFERENTIAL_PRESSURE           差压传感器故障。
+4            HL_FAILURE_FLAG_ABSOLUTE_PRESSURE               绝对压力传感器故障。
+8            HL_FAILURE_FLAG_3D_ACCEL                        加速度计传感器故障。
+16           HL_FAILURE_FLAG_3D_GYRO                         陀螺仪传感器故障。
+32           HL_FAILURE_FLAG_3D_MAG                          磁力计传感器故障。
+64           HL_FAILURE_FLAG_TERRAIN                         地形子系统故障。
+128          HL_FAILURE_FLAG_BATTERY                         电池故障/电池电量严重
+不足。
+256          HL_FAILURE_FLAG_RC_RECEIVER                     RC 接 收 器 故 障 / 没 有
+rc 连接。
+512          HL_FAILURE_FLAG_OFFBOARD_LINK                   板外链路故障。
+1024         HL_FAILURE_FLAG_ENGINE                          发动机故障。
+2048         HL_FAILURE_FLAG_GEOFENCE                        违反地理围栏。
+4096         HL_FAILURE_FLAG_ESTIMATOR                       估计器失败,例如测量
+拒绝或大方差。
+8192         HL_FAILURE_FLAG_MISSION                         任务失败。
+
+### 4.8 应答消息提示包(#270)
+
+字段名      类型                              说明
+Text     string   提示信息,如:上传成功、命令执行失败等
+Cmd      int      控制指令类型,0 表示系统下发,其他值表示具体业务命令类型
+Code     int      状态码
+子结构 code:系统状态码表
+code 码                              说明
+10000    无人机准备就绪
+10001    无人机已连接
+10002    无人机数据连接中...
+10003    无人机断开连接
+10011    RTK 数据已连接
+10012    RTK 数据断开连接
+10013    RTK 就绪中...
+10014    GPS 定位中...
+10021    遥控已连接
+10022    遥控未连接
+10023    已切换内场遥控权限
+10024    已切换外场遥控权限
+10031    图传链路已连接
+10032    图传链路数据已断开
+10033    已切换图传数据链路
+10041    1.4G 链路已连接
+10042    1.4G 链路数据已断开
+10043    已切换 1.4G 数据链路
+10051    4G
+10052    4G
+10053    已切换 4G
+10061    申请遥控权限
+10062    已获取遥控权限
+10063    拒绝遥控权限
+10075    降准降落识别失败,飞机前往备降点
+10076 降准降落模块离线
+示例数据(JSON)
+```json
+{
+"Text": " GPS 定位中...",
+"Cmd": 21,
+"Code": 10014
+}
+```
+
+### 4.9 避障信息(#272)
+
+字段名         类型                         说明
+MinDistance       int     避障最近检测到的障碍物距离,单位:cm
+MaxDistance       int     避障最远检测到的障碍物距离,单位:cm
+
+Distances          float[]     360 度避障距离数组,长度 36,每隔 10 度一个距离值,
+单位:cm
+Type               int         感知器类型(具体数值含义根据协议定义,如视觉、雷达
+等)
+Enable             bool        是否触发避障,true=已触发,false=未触发
+ObsDistance        double      避障生效的距离阈值,单位:米
+ObsEnableHeight float         避障生效的最小飞行高度,单位:米
+示例数据(JSON)
+```json
+{
+"MinDistance": 180,
+"MaxDistance": 800,
+"Distances": [180.0, 225.0, 350.0, 525.0, 800.0, 805.0, 800.0, 805.0, 800.0, 805.0,
+800.0, 805.0, 800.0, 805.0, 800.0, 805.0, 800.0, 805.0, 800.0, 805.0, 800.0, 805.0, 800.0,
+805.0, 800.0, 805.0, 800.0, 805.0, 800.0, 805.0, 800.0, 805.0, 800.0, 525.0, 350.0, 225.0],
+"Type": 1,
+"Enable": true,
+"ObsDistance": 3.0,
+"ObsEnableHeight": 2.0
+}
+```
+
+### 4.10 无人机飞行状态(#274)
+
+data                       类型                 说明
+true                                bool           true 起飞,false 降落
+### 4.11 健康管理模块(#275)
+
+字段名              类型                        说明
+AVI         UAV_Com_Health          航电系统健康状态
+BAT         UAV_Com_Health          电池健康状态
+CHUTE       UAV_Com_Health          降落伞健康状态
+OBS         UAV_Com_Health          避障健康状态
+RTK         UAV_Com_Health          RTK 健康状态
+SPEAK       UAV_Com_Health          喊话器健康状态
+GPS         UAV_Com_Health          GPS 健康状态
+VPN         UAV_Com_Health          5G VPN 网络健康状态
+SPE         UAV_Com_Health          1.4G 专网健康状态
+子结构:UAV_Com_Health(单个子系统健康报告)
+字段名          类型                          说明
+Code            string    健康信息码,唯一标识该类问题或状态
+Title           string    健康信息标题,简要描述问题
+Description     string    健康信息详细描述
+WarningLevel    string    预警等级,对应
+WarningLevel 健康信息等级
+WarningLevel              说明         类型
+NORMAL                    正常             enum
+NOTICE                    通知             enum
+
+CAUTION                   提醒         enum
+WARNING                   警告         enum
+SERIOUS_WARNING           严重警告       enum
+1.4G 专网状态码说明
+code      title       WarningLevel
+0x1110000   信号强         NORMAL
+0x1110001   信号中         NOTICE
+0x110002    信号差         WARNING
+4/5G VPN 网状态码说明
+code      title       WarningLevel
+0x1210000   信号强         NORMAL
+0x1210001   信号中         NOTICE
+0x1210002   信号差         WARNING
+GPS 态码说明
+code         title           WarningLevel
+0x1310000   信号强                NORMAL
+0x1310001   GPS 未定位            NOTICE
+0x1310002   无 GPS 信号           WARNING
+0x1310003   GPS 异常             WARNING
+航电状态码说明
+code          title              WarningLevel
+0x1510000   航电系统正常              NORMAL
+0x1510001   传感器异常               SERIOUS_WARNING
+0x1510002   GPS 异常异常            SERIOUS_WARNING
+0x1510003   角速度计异常              SERIOUS_WARNING
+0x1510004   加速度计异常              SERIOUS_WARNING
+0x1510005   气压计异常               SERIOUS_WARNING
+0x1510006   指南针异常               SERIOUS_WARNING
+0x1510007   IMU 异常              SERIOUS_WARNING
+0x1510008   航电板过压               SERIOUS_WARNING
+0x1510009   航电板欠压               SERIOUS_WARNING
+0x1510010   航电板过流               SERIOUS_WARNING
+电池状态码说明
+code                 title             WarningLevel
+0x1610000   电池系统正常                    NORMAL
+0x1610001   电池不在位                     SERIOUS_WARNING
+0x1610002   电池数量过少                    SERIOUS_WARNING
+0x1610003   电池电压过低                    SERIOUS_WARNING
+0x1610004   电池电压过高                    SERIOUS_WARNING
+0x1610005   电池电流过大                    SERIOUS_WARNING
+0x1610006   电池充放电次数过大                 SERIOUS_WARNING
+0x1610007   电池温度过低                    WARNING
+0x1610008   电池温度过高                    SERIOUS_WARNING
+
+0x1610010   航电板过流                SERIOUS_WARNING
+降落伞状态码说明
+code            title          WarningLevel
+0x1710000   降落伞系统正常           NORMAL
+0x1710001   降落伞未启用            NORMAL
+0x1710002   控制器不在线            WARNING
+0x1710003   伞包未连接             WARNING
+0x1710004   降落伞故障             SERIOUS_WARNING
+避障状态码说明
+code           title         WarningLevel
+0x1810000   避障系统正常           NORMAL
+0x1810001   避障不在位            SERIOUS_WARNING
+0x1810002   避障未启用            NOTICE
+0x1810003   避障故障             SERIOUS_WARNING
+降落伞状态码说明
+code               title        WarningLevel
+0x1910000   RTK 系统正常              NORMAL
+0x1910001   RTK 未启用               NOTICE
+0x1910002   未接收到 RTCM 数据          NOTICE
+0x1910003   RTK 没有进入 FIX          NOTICE
+喊话器链路状态码说明
+code            title       WarningLevel
+0x2210000   喊话器通信正常           NORMAL
+0x2210001 喊话器通信异常            WARNING
+示例数据(JSON)
+```json
+{
+"AVI": {
+"Code": "0x1510000",
+"Title": "航电系统正常",
+"Description": "航电系统运行状态正常,无异常",
+"WarningLevel": "Normal"
+},
+"BAT": {
+"Code": "0x1610002",
+"Title": "电池电量偏低",
+"Description": "当前电池电量为 23%,建议尽快安排降落或返航充电",
+"WarningLevel": "Warning"
+},
+"CHUTE": {
+"Code": "0x1710000",
+"Title": "降落伞正常",
+"Description": "降落伞运行状态正常,无异常",
+"WarningLevel": "Normal"
+},
+"OBS": {
+"Code": "0x1810000",
+"Title": "避障系统正常",
+"Description": "避障系统运行状态正常,无异常",
+"WarningLevel": "Normal"
+},
+"RTK": {
+"Code": "0x1910000",
+"Title": "RTK 正常",
+"Description": "RTK 运行状态正常,无异常",
+"WarningLevel": "Normal"
+},
+"SPEAK": {
+"Code": "0x2210000",
+"Title": "喊话器正常",
+"Description": "喊话器运行状态正常,无异常",
+"WarningLevel": "Normal"
+},
+"GPS": {
+"Code": "0x1310000",
+"Title": "GPS 正常 信号强",
+"Description": "GPS 运行状态正常,无异常",
+"WarningLevel": "Normal"
+},
+"VPN": {
+"Code": "0x1210000",
+"Title": "5G VPN 网络正常 信号强",
+"Description": "5G VPN 网络运行状态正常,无异常",
+"WarningLevel": "Normal"
+},
+"SPE": {
+"Code": "0x1110000",
+"Title": "1.4G 专网正常 信号强",
+"Description": "1.4G 专网运行状态正常,无异常",
+"WarningLevel": "Normal"
+}
+}
+```
+
+### 4.12 健康管理预警信息(#276)
+
+字段名                 类型                 说明
+Code                   string       设备健康信息码,唯一标识某个健康状态/预警
+Title                  string       健康信息标题,用于显示或简要说明
+Description            string       健康信息描述,详细说明问题或状态
+WarningLevel           string       健康预警等级,如 NORMAL、WARNING 、
+
+SERIOUS_WARNING 等
+WarningLevel 预警等级参考
+等级值                                              说明
+NORMAL                         正常,无异常
+WARNING                        警告,存在需要关注的问题,但不影响继续飞行
+SERIOUS_WARNING                严重警告,问题较严重,建议尽快处理或返航
+示例数据(JSON)
+```json
+{
+"code": "0x1910001",
+"description": "",
+"title": "RTK 未启用",
+"warningLevel": "NOTICE"
+}
+```
+
+### 4.13 精准降落视觉识别事件通知(#279)
+
+字段名                类型                                    说明
+Code          int             降落状态码(具体数值含义根据协议定义,如降落准备
+中、降落中、已降落等)
+Text          string          降落状态提示文本,配合 Code 使用,用于展示当前降
+落进程或结果
+示例数据(JSON)
+```json
+{
+"code": 0,
+"text": "精准降落视觉识别成功"
+}
+```
+
+### 4.14 视频开始或停止推流结果反馈(#280)
+
+字段        类型                                   说明
+Code        int        返回码,0 表示成功,其他表示失败
+Msg         string     返回消息
+Cmd   string 541 开始推流,542 停止推流
+示例数据(JSON)
+```json
+{
+"code": 0,
+"msg": "推流成功",
+"cmd": 541
+}
+```
+
+### 4.15 下视觉图像(#301)
+
+字段              类型                             说明
+stereo_image       string 双目图像数据,通常为 Base64 编码字符串。
+示例数据(JSON)
+```json
+{
+"stereo_image": "iVBORw0KGgoAAAANSUhEUgAA..."
+}
+```
+
+### 4.16 固件版本上报(#302)
+
+字段                 类型                                   说明
+
+firmware_version            string   固件版本(整包版本)。
+protocol_version            string   协议版本。
+app_version   string                 应用程序版本
+示例数据(JSON)
+```json
+{
+"firmware_version": "MT02_V1.2.3_20260708",
+"protocol_version": "2.1.0",
+"app_version": "1.5.8"
+}
+```
+
+### 4.17 无人机设备底层参数配置(#303)
+
+字段           类型                                    说明
+result             int         返回结果,0:成功,其他:失败。
+Params             object      飞控参数信息。
+timestamp          decimal     消息发送时的 13 位 Unix 时间戳(毫秒)。
+子结构 Params 对象
+字段                   类型                        说明
+battery_low_threshold                 float    电池低电量阈值(%)。
+battery_critical_threshold            float    电池临界低电量阈值(%)。
+battery_emergency_threshold           float    电池严重低电量阈值(%)。
+low_battery_action                    string   电池低电量动作。
+height_limit                          float    高度限制(m)。
+distance_limit                        float    距离限制(m)。
+geofence_action                       string   冲出围栏动作。
+return_height                         float    返航高度(m)。
+return_speed                          float    返航速度(m/s)。
+return_type                           string   返航类型。
+obstacle_avoidance_enable             int      避障开关,0:关闭,1:开启。
+obstacle_avoidance_distance           float    避障距离(m)。
+visual_sensor_enable                  int      下视传感器开关,0:关闭,1:开启。
+april_tag_id                          int      精准降落二维码编号。
+precision_land_mode                   string   精准降落模式。
+data_link_loss_action                 string   数据链路信号丢失保护动作。
+rc_loss_action                        string   RC 信号丢失保护动作。
+parachute_safety_enable               int      降落伞安全开关,0:关闭,1:开启。
+four_g_lost_action                    string   4G 失联动作,
+Continue:继续
+Return:返航
+Hover:悬停
+landing:降落
+terminate:终止
+disarm:停桨
+obstacle_avoidance_switch             int      键控开关,0:关闭,1:开启。
+rtk_switch                            int      RTK 开关,0:关闭,1:开启。
+
+示例数据(JSON)
+```json
+{
+"result": 0,
+"Params": {
+"battery_low_threshold": 30.0,
+"battery_critical_threshold": 20.0,
+"battery_emergency_threshold": 10.0,
+"low_battery_action": "ReturnHome",
+"height_limit": 120.0,
+"distance_limit": 5000.0,
+"geofence_action": "ReturnHome",
+"return_height": 80.0,
+"return_speed": 8.0,
+"return_type": "SmartReturn",
+"obstacle_avoidance_enable": 1,
+"obstacle_avoidance_distance": 5.0,
+"visual_sensor_enable": 1,
+"april_tag_id": 1001,
+"precision_land_mode": "AprilTag",
+"data_link_loss_action": "Hover",
+"rc_loss_action": "ReturnHome",
+"parachute_safety_enable": 1,
+"four_g_lost_action: " Continue ",
+"obstacle_avoidance_switch": 1,
+"rtk_switch": 1,
+},
+"timestamp": 1751961600123
+}
+```
+
+### 4.18 外挂装置主动上报(#304)
+
+字段                    类型                           说明
+DeviceType                         Enums       外挂装置类型
+msgType                            Enums       消息类型
+val                                Iint        值
+子结构:DeviceType
+枚举值               十六进制          十进制                     说明
+ClampStatusReport           0x02          2         抱夹状态主动上报      1 正常 0 异常 2 锁住 3
+放开
+ServoLocationReport         0x06          6         舵机位置主动上报      msgType
+BoxCheckReport              0x0A          10        货箱存在状态主动上报 true false
+GetBoxRFIDReport            0x0C          12        箱子 RFID 主动上报      string
+AirspeedReport              0x0E          14        空气速度计数值上报         int
+子结构:msgType
+枚举值          十六进制 十进制                                     说明
+Get            0x01        1         获取
+
+Set               0x02        2            设置
+Notify            0x03        3            通知
+
+### 4.19 接入成功(#2456)
+
+data                   类型                          说明
+接入成功                              string
+### 4.20 断开连接(#2457)
+
+data                   类型                          说明
+断开连接                              string
+
+## Base 结构体(飞机控制)-Data 详细说明
+
+### 4.21 获取飞机底层参数(#281)
+
+data                   类型                          说明
+Null                              string              获取参数
+```json
+{
+"DataType ": 281,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.22 配置飞机底层参数(#282)
+
+data                   说明                                类型 1
+name           参数名称                          string
+type           参数类别 1:float 2:string         Int
+value          参数值                           float
+参数说明
+字段(name)                     类型(type)                    说明
+battery_low_threshold                  float              电池低电量阈值(%)。
+battery_critical_threshold             float              电池临界低电量阈值(%)。
+battery_emergency_threshold            float              电池严重低电量阈值(%)。
+low_battery_action                     string             电池低电量动作。
+height_limit                           float              高度限制(m)。
+distance_limit                         float              距离限制(m)。
+geofence_action                        string             冲出围栏动作。
+return_height                          float              返航高度(m)。
+return_speed                           float              返航速度(m/s)。
+return_type                            string             返航类型。
+obstacle_avoidance_enable              int                避障开关,0:关闭,1:开启。
+obstacle_avoidance_distance            float              避障距离(m)。
+visual_sensor_enable                   int                下视传感器开关,0:关闭,1:开启。
+april_tag_id                           int                精准降落二维码编号。
+precision_land_mode                    string             精准降落模式。
+
+data_link_loss_action            string           数据链路信号丢失保护动作。
+rc_loss_action                   string           RC 信号丢失保护动作。
+parachute_safety_enable          int              降落伞安全开关,0:关闭,1:开启。
+four_g_lost_action               string           4G 失联动作,
+Continue:继续
+Return:返航
+Hover:悬停
+landing:降落
+terminate:终止
+disarm:停桨
+obstacle_avoidance_switch        int              键控开关,0:关闭,1:开启。
+rtk_switch                       int              RTK 开关,0:关闭,1:开启。
+```json
+{
+"DataType ": 282,
+"UAVType": 0,
+"Data":{
+"name": "battery_low_threshold",
+"type": 1,
+"value": 20
+},
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.23 云台 can 包透传(#514)
+
+data                 说明                      类型
+mountId          云台 id            Integer
+payload          云台控制指令数组         Integer[]
+```json
+{
+"DataType ": 514,
+"UAVType": 0,
+"Data":{
+"mountId": 46,
+"payload": [165,9,3,0,57]
+},
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.24 MAVLINK 数据包透传(#526)
+
+数据包透传
+data                              说明                    类型
+FE0600FFBE0B0000030001011599              Mavlink 16 进制字符串       String
+```json
+{
+"DataType ": 526,
+"UAVType": 0,
+"Data":"FE0600FFBE0B0000030001011599",
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.25 改变无人机模式(#2049)
+
+Data    类型                           说明
+0            int    手动模式(Manual)
+1            int    定高模式(Altitude)
+2            int    定点模式(Position)
+3            int    自动飞行航线任务(Auto)
+4            int    特技模式(Acro)
+5            int    板外模式(Off_board)
+6            int    自稳模式(Stabilized)
+7            int    保持模式/悬停(Hold)
+8            int    快速刹车悬停(Brake)
+9            int    降落模式(Land)
+10           int    起飞模式(Take_off)
+11           int    返航模式(Auto_rtl)
+12           int    自动跟随模式(Auto_Follow_Target)
+20           int    就绪(Auto_Readly)
+21           int    精准降落模式(Auto_PrecLand)
+```json
+{
+"DataType ": 2049,
+"UAVType": 0,
+"Data":1,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.26 无人机解锁(#2050)
+
+data                  类型                说明
+true                        bool           True 接管 false 解锁
+```json
+{
+"DataType ": 2050,
+"UAVType": 0,
+"Data":trye,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.27 无人机上传航线(#2051)
+
+上传航线后触发#261
+字段                 类型                         说明
+AutoSpeed             int                       航线自动速度(m/s)
+
+FinishedAction            string                     航线结束后飞机执行的动作
+IsStableHover             bool                       是否悬停等待
+MaxFlightSpeed            float                      航线飞行最大速度(m/s)
+WayPoints                 List<WavePoint>            航点列表
+flightSortiesID           string                     飞行架次号
+taskId                    string                     任务 ID
+rallyList                 List<LocationCoordinate>   备降点信息
+QrCode                    string                     精准降落二维码编号
+LandHeight                float                      降落点海拔高度(m)
+GlobalRthHeight      float                           安全返航高度(m)
+FinishedAction 类型种类
+FinishedAction                  说明                  类型
+NO_ACTION                              悬停                     String
+GO_HOME                                返航                     String
+AUTO_LAND                              原地降落                   String
+PRE_LAND                               精准降落                   String
+GO_FIRST_WAYPOINT                      返航后悬停                  String
+子结构体:WavePoint
+字段                     类型                                     说明
+Frame            int                  高度定位坐标类型(0:相对于海拔;3:相对于 Home
+点)
+Coordinate       LocationCoordinate   经纬度信息
+Action           WaypointActionType   航点动作
+Speed   float                         当前航点飞行速度(m/s)
+子结构体:WaypointAction
+字段            类型                  说明
+ActionType       string   动作类型
+Param1           int      停留时间(秒)
+Param2           int      接受半径(米)
+Param3           int      传递半径(可忽略)
+Param4   int    航向角(0~360,-1 表示忽略)
+子结构体:LocationCoordinate
+字段            类型        说明
+Altitude        float     高度(m)
+Latitude        double    纬度
+Longitude       double    经度
+子结构:WaypointActionType 航点动作
+WaypointActionType                                  说明             类型
+STAY                               飞机悬停时间,以毫秒为单位                      Integer
+START_TAKE_PHOTO                   开始拍摄照片                             String
+START_RECORD                       开始录制视频                             String
+STOP_RECORD                        停止录制视频                             String
+ROTATE_AIRCRAFT                    旋转飞机的偏航,参数取值范围(-180~180)           String
+
+GIMBAL_PITCH                云台姿态控制(-90~30)           String
+TRIGGER                     间距,间时拍照                  String
+CAMERA_ZOOM                 相机变焦,参数为变焦倍数             String
+
+示例数据(JSON)
+```json
+{
+"DataType ": 2051,
+"UAVType": 0,
+"Data": {
+"AutoSpeed": 8,
+"FinishedAction": " STAY ",
+"IsStableHover": true,
+"MaxFlightSpeed": 12,
+"WayPoints": [
+{
+"Frame": 0,
+"Coordinate": {
+"Altitude": 120,
+"Latitude": 31.230416,
+"Longitude": 121.473701
+},
+"Action": {
+"ActionType": "Waypoint",
+"Param1": 5,
+"Param2": 2,
+"Param3": 0,
+"Param4": 90
+},
+"Speed": 8
+},
+{
+"Frame": 0,
+"Coordinate": {
+"Altitude": 120,
+"Latitude": 31.231,
+"Longitude": 121.4745
+},
+"Action": {
+"ActionType": "TakePhoto",
+"Param1": 3,
+"Param2": 2,
+"Param3": 0,
+"Param4": -1
+},
+"Speed": 6
+}
+],
+"flightSortiesID": "SORTIE202607080001",
+"taskId": "TASK202607080001",
+"rallyList": [
+{
+"Altitude": 30,
+"Latitude": 31.2295,
+"Longitude": 121.472
+}
+],
+"QrCode": "APRILTAG_1001",
+"LandHeight": 15,
+"GlobalRthHeight": 80
+},
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.28 无人机下载航线(#2052)
+
+下载航线后触发#261
+data                    类型                说明
+Null                           string       获取参数
+```json
+{
+"DataType ": 2052,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.29 无人机起飞(#2054)
+
+data                    类型                说明
+Null                           string       获取参数
+```json
+{
+"DataType ": 2054,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.30 无人机降落(#2055)
+
+data                    类型                说明
+Null                           string       获取参数
+
+```json
+{
+"DataType ": 2055,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.31 无人机暂停(#2056)
+
+data                  类型                  说明
+Null                        string         获取参数
+```json
+{
+"DataType ": 2056,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.32 无人机继续任务(#2057)
+
+data                  类型                  说明
+Null                        string         获取参数
+```json
+{
+"DataType ": 2057,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.33 无人机结束任务(#2058)
+
+data                  类型                  说明
+Null                        string         获取参数
+```json
+{
+"DataType ": 2058,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.34 取消航线任务(#2071)
+
+data                  类型                  说明
+Null                        string         获取参数
+
+```json
+{
+"DataType ": 2071,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.35 下置摄像头图传(#2066)
+
+字段                          类型                           说明
+path                       string                  图传地址
+val                        Iint                    1 开启图传        0 关闭图传 2 飞机默认开启
+
+```json
+{
+"DataType ": 2066,
+"UAVType": 0,
+"Data":{
+"path":"https://.....",
+"val ": 1,
+},
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.36 紧急迫降(#2069)
+
+data                       类型                  说明
+Null                                string    获取参数
+```json
+{
+"DataType ": 2069,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.37 指点飞行(#2070)
+
+字段            类型                               说明
+AirlineCode          string       航线编号
+UAVCode              string       无人机编号
+UAVType              int          无人机类型
+SerialNumber         string       无人机序列号
+SwitchModel          bool         是否切换飞行模式
+ControlType          int          控制类型(根据协议定义)
+latitude             float        纬度
+
+longitude               float   经度
+altitude                float   飞行高度(m)
+speed                   float   飞行速度(m/s)
+heading                 int     飞行航向角(°,0~360)
+```json
+{
+"DataType ": 2070,
+"UAVType": 0,
+"Data":{
+"latitude": 33.3180863,
+"longitude": 120.0978796,
+"altitude": 61.52,
+"speed": 5.0,
+"heading": 30
+},
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.38 手动控制飞行(#2072)
+
+data                             说明                    类型 1
+front                 前进,单位 0.1m,例如:front=10,飞机前进 1 米        Integer
+back                  后退,单位 0.1m                             Integer
+left                  左平移,单位 0.1m                            Integer
+right                 右平移,单位 0.1m                            Integer
+leftTurn              左转,单位 1°                               Integer
+rightTurn             右转,单位 1°                               Integer
+up                    爬升,单位 0.1m                             Integer
+down                  下降,单位 0.1m                             Integer
+```json
+{
+"DataType ": 2072,
+"UAVType": 0,
+"Data":{"front": 10},
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.39 高空紧急停桨(#2073)-停桨后开伞
+
+data                  类型                 说明
+Null                                string        获取参数
+
+```json
+{
+"DataType ": 2073,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.40 地面紧急停桨(#2074)-停桨后不开伞
+
+data                  类型                       说明
+Null                              string           获取参数
+
+```json
+{
+"DataType ": 2074,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.41 手动接管(#2075)
+
+data                   类型                      说明
+Null                              string               获取参数
+```json
+{
+"DataType ": 2075,
+"UAVType": 0,
+"Data":null,
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.42 外挂装置控制(#2099)
+
+字段                       类型                       说明
+DeviceType                             Enums       外挂装置类型
+msgType                                Enums       消息类型
+val                                    Iint        值
+子结构:DeviceType
+枚举值               十六进制           十进制                    说明
+SetServoaction                 0x03           3         调度控制舵机动作     msgType
+GetServoaction                 0x04           4         请求舵机动作
+GetServoLocation               0x05           5         获取舵机位置
+GetBoxCheck                    0x09           9         货箱检测(是否已装箱)
+GetBoxRFID                     0x0B           11        获取箱子 RFID
+HandleReleaseBox               0x10           16        手动卸箱
+子结构:msgType
+枚举值              十六进制 十进制                                    说明
+Get                0x01       1            获取
+
+Set                0x02            2    设置
+Notify             0x03            3    通知
+```json
+{
+"DataType ": 2074,
+"UAVType": 0,
+"Data":{
+"DeviceType": 16,
+"msgType": 2074,
+"val": 2074,
+},
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.43 飞控日志下载(#2100)
+
+字段                    类型                             说明
+Id           int                            日志 id
+url          string                         日志上传 URL(只支持 PUT 上传)
+```json
+{
+"DataType ": 2100,
+"UAVType": 0,
+"Data":{
+"id": 1,
+"url": "XXXX"
+},
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```
+
+### 4.44 OTA 升级(#2101)
+
+字段                         类型           说明
+sn                         string       无人机 SN
+model                      string       无人机型号
+url                        string       固件下载链接
+md5                        string       固件 md5 值
+package_name               string       固件包名
+version                    string       版本号
+amend                      string       更新内容
+```json
+{
+"DataType ": 2101,
+"UAVType": 0,
+"Data":{
+"sn": "xxxx",
+"model": "Q20",
+"url": "https://www.xxx.com/xxx.zip",
+"md5": "xxxxxx",
+"package_name": "PACK-v2.5.1",
+"version": "v2.5.1",
+"amend": "1.集成 xxx 功能"
+},
+"SystemID": "UAV000001",
+"TimeStamp": "2026-07-08T15:30:45.123+08:00"
+}
+```

+ 6 - 0
motian_bridge/__init__.py

@@ -0,0 +1,6 @@
+"""Motian MQTT / serial passthrough bridge."""
+
+from .bridge import Bridge, BaseMessage, decode_command, make_report
+from .config import Config
+
+__all__ = ["Bridge", "BaseMessage", "Config", "decode_command", "make_report"]

+ 235 - 0
motian_bridge/bridge.py

@@ -0,0 +1,235 @@
+"""MQTT / serial bridge with optional raw forwarding and parsed telemetry."""
+
+from dataclasses import dataclass
+from datetime import datetime
+import json
+import logging
+import re
+import threading
+from typing import Any, Callable
+
+from .config import Config
+from .telemetry import Telemetry
+
+
+LOG = logging.getLogger(__name__)
+_HEX = re.compile(r"(?:[0-9a-fA-F]{2})+")
+MAVLINK_PASSTHROUGH = 526
+MAX_MQTT_PAYLOAD = 65536
+
+
+@dataclass(frozen=True)
+class BaseMessage:
+    data_type: int
+    uav_type: int
+    data: Any
+    system_id: str
+    timestamp: str
+
+
+def decode_command(payload: bytes, config: Config) -> BaseMessage:
+    """Validate the Base envelope. Data for future handlers is left untouched."""
+    if len(payload) > MAX_MQTT_PAYLOAD:
+        raise ValueError("MQTT payload too large")
+    try:
+        value = json.loads(payload.decode("utf-8"))
+    except (UnicodeError, json.JSONDecodeError) as exc:
+        raise ValueError("invalid JSON Base payload") from exc
+    if not isinstance(value, dict):
+        raise ValueError("Base payload must be an object")
+    if value.get("SystemID") != config.system_id:
+        raise ValueError("SystemID does not match this bridge")
+    data_type = value.get("DataType")
+    if type(data_type) is not int:
+        raise ValueError("DataType must be an integer")
+    uav_type = value.get("UAVType")
+    if type(uav_type) is not int or uav_type != config.uav_type:
+        raise ValueError("UAVType does not match this bridge")
+    if "Data" not in value:
+        raise ValueError("Data is required")
+    timestamp = value.get("TimeStamp")
+    if not isinstance(timestamp, str):
+        raise ValueError("TimeStamp must be a string")
+    return BaseMessage(data_type, uav_type, value["Data"], value["SystemID"], timestamp)
+
+
+def decode_hex(data: Any, max_bytes: int) -> bytes:
+    if not isinstance(data, str) or len(data) > 2 * max_bytes or not _HEX.fullmatch(data):
+        raise ValueError("Data must be nonempty even-length HEX within the configured limit")
+    return bytes.fromhex(data)
+
+
+def make_report(config: Config, data_type: int, data: Any) -> bytes:
+    if type(data_type) is not int:
+        raise ValueError("DataType must be an integer")
+    envelope = {
+        "DataType": data_type,
+        "UAVType": config.uav_type,
+        "Data": data,
+        "SystemID": config.system_id,
+        "TimeStamp": datetime.now().astimezone().isoformat(timespec="milliseconds"),
+    }
+    return json.dumps(envelope, ensure_ascii=False, allow_nan=False,
+                      separators=(",", ":")).encode("utf-8")
+
+
+Handler = Callable[[BaseMessage, "Bridge"], None]
+
+
+class Bridge:
+    """Uses injected MQTT/serial objects; the CLI owns concrete dependencies."""
+
+    def __init__(self, config: Config, mqtt_client: Any, serial_factory: Callable[..., Any]):
+        self.config = config
+        self.mqtt = mqtt_client
+        self.serial_factory = serial_factory
+        self._handlers: dict[int, Handler] = {}
+        self._mqtt_connected = threading.Event()
+        self._stop = threading.Event()
+        self._serial_lock = threading.Lock()
+        self._serial: Any = None
+        self._thread: threading.Thread | None = None
+        self._telemetry = Telemetry(config)
+        self.mqtt.on_connect = self._on_connect
+        self.mqtt.on_disconnect = self._on_disconnect
+        self.mqtt.on_message = self._on_message
+
+    def register_handler(self, data_type: int, handler: Handler) -> None:
+        """Register a future business-command handler (526 remains reserved)."""
+        if type(data_type) is not int or data_type == MAVLINK_PASSTHROUGH or not callable(handler):
+            raise ValueError("handler requires a non-526 integer DataType and a callable")
+        self._handlers[data_type] = handler
+
+    def publish_report(self, data_type: int, data: Any, *, passthrough: bool = False) -> bool:
+        """Publish future reports to /RECEIVE, or raw data to /PUBLISH."""
+        if not self._mqtt_connected.is_set():
+            return False
+        topic = self.config.publish_topic if passthrough else self.config.receive_topic
+        try:
+            payload = make_report(self.config, data_type, data)
+        except (TypeError, ValueError):
+            LOG.exception("invalid report data; message discarded")
+            return False
+        try:
+            result = self.mqtt.publish(topic, payload, qos=0, retain=False)
+        except Exception:
+            LOG.exception("MQTT publish failed")
+            return False
+        if result.rc != 0:
+            LOG.warning("MQTT publish failed: rc=%s", result.rc)
+            return False
+        return True
+
+    def start(self) -> None:
+        self._stop.clear()
+        self._thread = threading.Thread(target=self._serial_loop, name="serial-reader", daemon=True)
+        self._thread.start()
+        self.mqtt.connect_async(self.config.mqtt_host, self.config.mqtt_port, self.config.mqtt_keepalive)
+        self.mqtt.loop_start()
+
+    def stop(self) -> None:
+        self._stop.set()
+        self._mqtt_connected.clear()
+        self.mqtt.disconnect()
+        self.mqtt.loop_stop()
+        with self._serial_lock:
+            serial_port, self._serial = self._serial, None
+        if serial_port is not None:
+            serial_port.close()
+        if self._thread is not None:
+            self._thread.join(timeout=3)
+
+    def _on_connect(self, client: Any, userdata: Any, flags: Any, reason_code: Any, properties: Any) -> None:
+        if reason_code != 0:
+            self._mqtt_connected.clear()
+            LOG.error("MQTT connection refused: %s", reason_code)
+            return
+        result, _mid = client.subscribe(self.config.obtain_topic, qos=0)
+        if result != 0:
+            LOG.error("MQTT subscribe failed: rc=%s", result)
+            self._mqtt_connected.clear()
+            return
+        self._mqtt_connected.set()
+        LOG.info("MQTT connected; subscribed to %s", self.config.obtain_topic)
+
+    def _on_disconnect(self, client: Any, userdata: Any, disconnect_flags: Any, reason_code: Any, properties: Any) -> None:
+        self._mqtt_connected.clear()
+        LOG.warning("MQTT disconnected: %s", reason_code)
+
+    def _on_message(self, client: Any, userdata: Any, message: Any) -> None:
+        if message.topic != self.config.obtain_topic or message.retain or not self._mqtt_connected.is_set():
+            LOG.warning("ignored wrong-topic, retained, or disconnected MQTT command")
+            return
+        try:
+            command = decode_command(message.payload, self.config)
+            if command.data_type == MAVLINK_PASSTHROUGH:
+                self._write_serial(decode_hex(command.data, self.config.max_command_bytes))
+            else:
+                handler = self._handlers.get(command.data_type)
+                if handler is None:
+                    LOG.warning("unsupported DataType=%s; command discarded", command.data_type)
+                else:
+                    handler(command, self)
+        except ValueError as exc:
+            LOG.warning("invalid MQTT command: %s", exc)
+        except Exception:
+            LOG.exception("command handler failed")
+
+    def _write_serial(self, data: bytes) -> None:
+        with self._serial_lock:
+            if self._serial is None:
+                LOG.warning("serial disconnected; command discarded")
+                return
+            try:
+                count = self._serial.write(data)
+                if count != len(data):
+                    raise OSError(f"short serial write: {count}/{len(data)}")
+            except Exception:
+                LOG.exception("serial write failed; command discarded")
+                self._serial.close()
+                self._serial = None
+
+    def _serial_loop(self) -> None:
+        while not self._stop.is_set():
+            with self._serial_lock:
+                current = self._serial
+            if current is None:
+                try:
+                    current = self.serial_factory(
+                        self.config.serial_port,
+                        self.config.serial_baudrate,
+                        timeout=0.2,
+                        write_timeout=0.5,
+                    )
+                    with self._serial_lock:
+                        if self._stop.is_set():
+                            current.close()
+                            return
+                        self._serial = current
+                    # A fresh serial link can be another flight controller.
+                    self._telemetry = Telemetry(self.config)
+                    LOG.info("serial connected: %s", self.config.serial_port)
+                except Exception as exc:
+                    LOG.warning("serial connection failed: %s", exc)
+                    self._stop.wait(self.config.reconnect_seconds)
+                    continue
+            try:
+                data = current.read(self.config.max_chunk_bytes)
+            except Exception as exc:
+                LOG.warning("serial read failed: %s", exc)
+                with self._serial_lock:
+                    if self._serial is current:
+                        self._serial = None
+                        current.close()
+                self._stop.wait(self.config.reconnect_seconds)
+                continue
+            try:
+                if data and self.config.publish_raw_mavlink and self._mqtt_connected.is_set():
+                    self.publish_report(MAVLINK_PASSTHROUGH, data.hex().upper(), passthrough=True)
+                if data:
+                    for data_type, report in self._telemetry.feed(data):
+                        self.publish_report(data_type, report)
+                for data_type, report in self._telemetry.tick():
+                    self.publish_report(data_type, report)
+            except Exception:
+                LOG.exception("telemetry processing failed; serial link preserved")

+ 49 - 0
motian_bridge/cli.py

@@ -0,0 +1,49 @@
+"""Production entry point using paho-mqtt and pyserial."""
+
+import argparse
+import logging
+import signal
+import threading
+
+from .bridge import Bridge
+from .config import Config
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Motian MQTT / MAVLink telemetry and serial bridge")
+    parser.add_argument("--config", required=True, help="path to JSON configuration")
+    parser.add_argument("--debug", action="store_true")
+    args = parser.parse_args()
+    logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO,
+                        format="%(asctime)s %(levelname)s %(name)s: %(message)s")
+
+    import paho.mqtt.client as mqtt
+    import serial
+
+    config = Config.from_file(args.config)
+    client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
+                         client_id=config.client_id, protocol=mqtt.MQTTv311)
+    if config.mqtt_username is not None:
+        client.username_pw_set(config.mqtt_username, config.mqtt_password())
+    if config.mqtt_tls:
+        client.tls_set(ca_certs=config.mqtt_ca_file,
+                       certfile=config.mqtt_cert_file, keyfile=config.mqtt_key_file)
+    client.reconnect_delay_set(min_delay=1, max_delay=30)
+    client.max_queued_messages_set(1)
+    bridge = Bridge(config, client, serial.serial_for_url)
+    done = threading.Event()
+
+    def shutdown(signum: int, frame: object) -> None:
+        done.set()
+
+    signal.signal(signal.SIGINT, shutdown)
+    signal.signal(signal.SIGTERM, shutdown)
+    bridge.start()
+    try:
+        done.wait()
+    finally:
+        bridge.stop()
+
+
+if __name__ == "__main__":
+    main()

+ 82 - 0
motian_bridge/config.py

@@ -0,0 +1,82 @@
+"""Runtime configuration. Secrets are read from the environment, not JSON."""
+
+from dataclasses import dataclass
+import json
+import os
+from pathlib import Path
+
+
+@dataclass(frozen=True)
+class Config:
+    system_id: str
+    uav_type: int
+    serial_port: str
+    serial_baudrate: int
+    mqtt_host: str
+    mqtt_port: int = 1883
+    mqtt_client_id: str = ""
+    mqtt_username: str | None = None
+    mqtt_password_env: str | None = None
+    mqtt_tls: bool = False
+    mqtt_ca_file: str | None = None
+    mqtt_cert_file: str | None = None
+    mqtt_key_file: str | None = None
+    mqtt_keepalive: int = 60
+    max_chunk_bytes: int = 1024
+    max_command_bytes: int = 4096
+    reconnect_seconds: float = 2.0
+    publish_raw_mavlink: bool = True
+
+    def __post_init__(self) -> None:
+        if not self.system_id or any(c in self.system_id for c in "/+#\x00"):
+            raise ValueError("system_id must be a nonempty single MQTT topic level")
+        if type(self.uav_type) is not int or self.uav_type < 0:
+            raise ValueError("uav_type must be a nonnegative integer assigned by the platform")
+        if not self.serial_port or not self.mqtt_host:
+            raise ValueError("serial_port and mqtt_host are required")
+        if not 1 <= self.serial_baudrate or not 1 <= self.mqtt_port <= 65535:
+            raise ValueError("invalid serial_baudrate or mqtt_port")
+        if not 1 <= self.max_chunk_bytes <= self.max_command_bytes:
+            raise ValueError("max_chunk_bytes must be positive and <= max_command_bytes")
+        if self.mqtt_keepalive < 1 or self.reconnect_seconds <= 0:
+            raise ValueError("invalid keepalive or reconnect interval")
+        if type(self.publish_raw_mavlink) is not bool:
+            raise ValueError("publish_raw_mavlink must be a boolean")
+        if self.mqtt_password_env and not self.mqtt_username:
+            raise ValueError("mqtt_username is required when mqtt_password_env is set")
+        if (self.mqtt_cert_file or self.mqtt_key_file or self.mqtt_ca_file) and not self.mqtt_tls:
+            raise ValueError("TLS certificate settings require mqtt_tls=true")
+        if bool(self.mqtt_cert_file) != bool(self.mqtt_key_file):
+            raise ValueError("mqtt_cert_file and mqtt_key_file must be set together")
+
+    @property
+    def obtain_topic(self) -> str:
+        return f"/OBTAIN/{self.system_id}"
+
+    @property
+    def publish_topic(self) -> str:
+        return f"/PUBLISH/{self.system_id}"
+
+    @property
+    def receive_topic(self) -> str:
+        return f"/RECEIVE/{self.system_id}"
+
+    @property
+    def client_id(self) -> str:
+        return self.mqtt_client_id or f"mavlink-bridge-{self.system_id}"
+
+    @classmethod
+    def from_file(cls, path: str | Path) -> "Config":
+        with open(path, encoding="utf-8") as file:
+            values = json.load(file)
+        if not isinstance(values, dict):
+            raise ValueError("configuration must be a JSON object")
+        return cls(**values)
+
+    def mqtt_password(self) -> str | None:
+        if self.mqtt_password_env is None:
+            return None
+        value = os.environ.get(self.mqtt_password_env)
+        if value is None:
+            raise ValueError(f"missing MQTT password environment variable: {self.mqtt_password_env}")
+        return value

+ 1 - 0
motian_bridge/dialects/__init__.py

@@ -0,0 +1 @@
+"""Generated MAVLink dialects."""

File diff ditekan karena terlalu besar
+ 8033 - 0
motian_bridge/dialects/v20/VKFly.py


+ 1 - 0
motian_bridge/dialects/v20/__init__.py

@@ -0,0 +1 @@
+"""MAVLink 2.0 dialect generated from the msg_definitions submodule."""

+ 226 - 0
motian_bridge/telemetry.py

@@ -0,0 +1,226 @@
+"""State derived from checksummed VKFly MAVLink messages.
+
+The wire parser and cached message sources live on the serial reader thread only.
+Times below are monotonic receive times, never the flight controller's boot clock.
+"""
+
+import logging
+import math
+import time
+from typing import Any
+
+from .config import Config
+from .dialects.v20 import VKFly
+
+
+LOG = logging.getLogger(__name__)
+# The generated module honors MAV_IGNORE_CRC from the environment by default.
+# Never permit that override for telemetry used as flight state.
+VKFly.MAVLINK_IGNORE_CRC = 0
+STALE_SECONDS = 5.0
+REPORT_SECONDS = 1.0
+_INVALID_AUTOPILOT = VKFly.MAV_AUTOPILOT_INVALID
+_AUTOPILOT_COMPONENT = VKFly.MAV_COMP_ID_AUTOPILOT1
+_ARMED_FLAG = VKFly.MAV_MODE_FLAG_SAFETY_ARMED
+
+
+def _enum_label(enum: str, value: int, prefix: str) -> str:
+    entry = VKFly.enums.get(enum, {}).get(value)
+    return entry.name.removeprefix(prefix) if entry else f"UNKNOWN_{value}"
+
+
+def _distance_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
+    r1, r2 = math.radians(lat1), math.radians(lat2)
+    dr = r2 - r1
+    dl = math.radians(lon2 - lon1)
+    a = math.sin(dr / 2) ** 2 + math.cos(r1) * math.cos(r2) * math.sin(dl / 2) ** 2
+    return 6371000.0 * 2 * math.asin(min(1.0, math.sqrt(a)))
+
+
+def _put_finite(data: dict[str, Any], name: str, value: float) -> None:
+    if math.isfinite(value):
+        data[name] = value
+
+
+class Telemetry:
+    """Parse MAVLink v1/v2 bytes and emit (DataType, Data) reports.
+
+    Neither the parser nor state is shared across serial reconnects. Non-flight
+    components and other systems never contribute to the selected UAV's state.
+    """
+
+    def __init__(self, config: Config):
+        self.config = config
+        self.parser = VKFly.MAVLink(None)
+        self.source: tuple[int, int] | None = None
+        self.latest: dict[str, tuple[Any, float]] = {}
+        self.batteries: dict[int, tuple[Any, float]] = {}
+        self.battery_dirty = False
+        self.last_flight_report: float | None = None
+        self.last_battery_report: float | None = None
+        self.last_armed: bool | None = None
+        self.last_missing_log: float | None = None
+
+    def feed(self, chunk: bytes, now: float | None = None) -> list[tuple[int, Any]]:
+        now = time.monotonic() if now is None else now
+        reports: list[tuple[int, Any]] = []
+        # parse_char returns at most one message, so feed bytes individually.
+        # Splitting and coalescing serial reads does not affect message framing.
+        for byte in chunk:
+            try:
+                message = self.parser.parse_char(bytes((byte,)))
+            except VKFly.MAVError as exc:
+                LOG.debug("discarded invalid MAVLink frame: %s", exc)
+                continue
+            if message is not None:
+                reports.extend(self._handle(message, now))
+        return reports
+
+    def _handle(self, message: Any, now: float) -> list[tuple[int, Any]]:
+        kind = message.get_type()
+        src = (message.get_srcSystem(), message.get_srcComponent())
+        if self.source is None:
+            if (kind != "HEARTBEAT" or src[1] != _AUTOPILOT_COMPONENT
+                    or message.autopilot == _INVALID_AUTOPILOT):
+                return []
+            self.source = src
+            LOG.info("locked MAVLink flight controller sys=%s comp=%s", *src)
+        if src != self.source:
+            return []
+
+        # A BMS frame with an unexpected ID must not appear as a usable pack.
+        if kind == "VK_BMS_STATUS" and message.bat_id not in (0, 1):
+            return []
+        self.latest[kind] = (message, now)
+        if kind == "HEARTBEAT":
+            armed = bool(message.base_mode & _ARMED_FLAG)
+            if armed != self.last_armed:
+                self.last_armed = armed
+                # Platform convention: "flying" means armed, not airborne.
+                return [(274, armed)]
+        elif kind == "VK_BMS_STATUS" and message.bat_id in (0, 1):
+            self.batteries[message.bat_id] = (message, now)
+            self.battery_dirty = True
+        elif kind == "STATUSTEXT":
+            # Generated Python text decoding assumes ASCII and irreversibly
+            # replaces UTF-8 bytes. Read the already CRC-validated frame's
+            # severity + char[50] wire payload instead.
+            frame = bytes(message.get_msgbuf())
+            header_size = 6 if frame[0] == VKFly.PROTOCOL_MARKER_V1 else 10
+            payload = frame[header_size:header_size + frame[1]]
+            text = payload[1:51].split(b"\x00", 1)[0]
+            text = text.decode("utf-8", "replace")
+            if text:
+                return [(260, {"severity": int(message.severity), "msg": text})]
+        return []
+
+    def _fresh(self, kind: str, now: float) -> Any | None:
+        entry = self.latest.get(kind)
+        return entry[0] if entry and 0 <= now - entry[1] <= STALE_SECONDS else None
+
+    def tick(self, now: float | None = None) -> list[tuple[int, Any]]:
+        now = time.monotonic() if now is None else now
+        reports: list[tuple[int, Any]] = []
+        heartbeat = self._fresh("HEARTBEAT", now)
+        if heartbeat is not None and (self.last_flight_report is None
+                                      or now - self.last_flight_report >= REPORT_SECONDS):
+            reports.append((258, self.flight_data(now, heartbeat)))
+            self.last_flight_report = now
+            self._log_missing(now)
+        if self.battery_dirty and (self.last_battery_report is None
+                                   or now - self.last_battery_report >= REPORT_SECONDS):
+            battery = self.battery_data(now)
+            self.battery_dirty = False
+            if battery is not None:
+                reports.append((257, battery))
+                self.last_battery_report = now
+        return reports
+
+    def _log_missing(self, now: float) -> None:
+        if self.last_missing_log is not None and now - self.last_missing_log < 60:
+            return
+        missing = [name for name in ("GLOBAL_POSITION_INT", "ATTITUDE", "VFR_HUD",
+                                    "GPS_RAW_INT", "HOME_POSITION", "VK_BMS_STATUS")
+                   if self._fresh(name, now) is None]
+        if missing:
+            LOG.debug("missing/stale MAVLink telemetry: %s", ", ".join(missing))
+        self.last_missing_log = now
+
+    def flight_data(self, now: float, heartbeat: Any) -> dict[str, Any]:
+        data: dict[str, Any] = {
+            "SystemID": self.config.system_id,
+            "Armed": bool(heartbeat.base_mode & _ARMED_FLAG),
+            "IsFlying": bool(heartbeat.base_mode & _ARMED_FLAG),
+            "DroneAutopilot": int(heartbeat.autopilot),
+            "UavType": int(heartbeat.type),
+            "FlightMode": _enum_label("VKFLY_CUSTOM_MODE", heartbeat.custom_mode,
+                                      "VKFLY_CUSTOM_MODE_"),
+        }
+        attitude = self._fresh("ATTITUDE", now)
+        if attitude:
+            _put_finite(data, "Roll", math.degrees(attitude.roll))
+            _put_finite(data, "Pitch", math.degrees(attitude.pitch))
+            _put_finite(data, "Yaw", math.degrees(attitude.yaw) % 360)
+        position = self._fresh("GLOBAL_POSITION_INT", now)
+        if position:
+            if abs(position.lat) <= 900000000 and abs(position.lon) <= 1800000000:
+                data.update(Latitude=position.lat / 1e7, Longitude=position.lon / 1e7)
+            data.update(Altitude=position.relative_alt / 1000,
+                        VelocityX=position.vx / 100, VelocityY=position.vy / 100,
+                        VelocityZ=position.vz / 100)
+        hud = self._fresh("VFR_HUD", now)
+        if hud:
+            for key, value in (("AirSpeed", hud.airspeed), ("GroundSpeed", hud.groundspeed),
+                               ("ClimbRate", hud.climb)):
+                _put_finite(data, key, value)
+        home = self._fresh("HOME_POSITION", now)
+        if home and abs(home.latitude) <= 900000000 and abs(home.longitude) <= 1800000000:
+            data.update(HomeLatitude=home.latitude / 1e7, HomeLongitude=home.longitude / 1e7,
+                        HomeAltitude=home.altitude / 1000, IsHomeLocationSet=True)
+            if position and "Latitude" in data:
+                distance = _distance_m(data["Latitude"], data["Longitude"],
+                                       data["HomeLatitude"], data["HomeLongitude"])
+                data["DistanceToHome"] = distance
+                data["SpaceDistToHome"] = math.hypot(
+                    distance, position.alt / 1000 - data["HomeAltitude"])
+        gps = self._fresh("GPS_RAW_INT", now)
+        if gps:
+            info: dict[str, Any] = {
+                "Type": 15,
+                "FixType": _enum_label("GPS_FIX_TYPE", gps.fix_type, "GPS_FIX_TYPE_"),
+            }
+            if gps.fix_type >= 2:
+                info["AbsoluteAlt"] = gps.alt / 1000
+            if gps.eph != 65535:
+                info["LevelDivisor"] = gps.eph / 100
+            if gps.epv != 65535:
+                info["VerticalDivisor"] = gps.epv / 100
+            if gps.satellites_visible != 255:
+                info["SatelliteCount"] = gps.satellites_visible
+            data["Gps"] = info
+        return data
+
+    def battery_data(self, now: float) -> dict[str, Any] | None:
+        packs = {id_: item[0] for id_, item in self.batteries.items()
+                 if 0 <= now - item[1] <= STALE_SECONDS}
+        if not packs:
+            return None
+        data: dict[str, Any] = {"SystemID": self.config.system_id}
+        for id_, pack in packs.items():
+            side = "Left" if id_ == 0 else "Right"
+            if pack.voltage:
+                data["Battery_Voltage" + side] = pack.voltage / 1000
+            data["Battery_Current" + side] = pack.current / 100
+            if 0 <= pack.cap_percent <= 100:
+                data["Battery_Remaining" + side] = pack.cap_percent
+        if len(packs) == 1:
+            pack = next(iter(packs.values()))
+            if pack.voltage:
+                data["Battery_Voltage"] = pack.voltage / 1000
+            data["Battery_Current"] = pack.current / 100
+            if 0 <= pack.cap_percent <= 100:
+                data["Battery_Remaining"] = pack.cap_percent
+            data["Battery_Temperature"] = pack.temperature
+            data["cycleIndex"] = pack.cyc_cnt
+            data["flags"] = pack.err_code
+        return data

+ 1 - 0
msg_definitions

@@ -0,0 +1 @@
+Subproject commit fce609a7d8063a59f7ca203b48f396ea517de5f2

+ 16 - 0
pyproject.toml

@@ -0,0 +1,16 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "motian-mavlink-bridge"
+version = "0.1.0"
+description = "Motian MQTT telemetry and MAVLink serial bridge"
+requires-python = ">=3.10"
+dependencies = ["paho-mqtt>=2,<3", "pyserial>=3.5,<4", "pymavlink>=2.4.50,<3"]
+
+[project.scripts]
+motian-mavlink-bridge = "motian_bridge.cli:main"
+
+[tool.setuptools.packages.find]
+include = ["motian_bridge*"]

+ 220 - 0
tests/test_bridge.py

@@ -0,0 +1,220 @@
+import json
+import os
+import threading
+import time
+import unittest
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import serial
+
+from motian_bridge import Bridge, Config, decode_command, make_report
+from motian_bridge.bridge import decode_hex
+
+
+def config(**changes):
+    values = dict(system_id="UAV000001", uav_type=7, serial_port="loop://",
+                  serial_baudrate=57600, mqtt_host="localhost")
+    values.update(changes)
+    return Config(**values)
+
+
+def command(data_type=526, data="FE0600FF", **changes):
+    value = dict(DataType=data_type, UAVType=7, Data=data,
+                 SystemID="UAV000001", TimeStamp="2026-09-28T12:00:00+08:00")
+    value.update(changes)
+    return json.dumps(value).encode()
+
+
+class FakeMqtt:
+    def __init__(self):
+        self.published = []
+        self.subscriptions = []
+        self.active = False
+
+    def subscribe(self, topic, qos):
+        self.subscriptions.append((topic, qos))
+        return (0, 1)
+
+    def publish(self, topic, payload, qos, retain):
+        self.published.append((topic, payload, qos, retain))
+        return SimpleNamespace(rc=0)
+
+    def connect_async(self, *args):
+        self.active = True
+
+    def loop_start(self):
+        pass
+
+    def disconnect(self):
+        self.active = False
+
+    def loop_stop(self):
+        pass
+
+
+class FakeSerial:
+    def __init__(self):
+        self.written = []
+        self.closed = False
+        self.to_read = []
+
+    def write(self, data):
+        self.written.append(data)
+        return len(data)
+
+    def read(self, size):
+        time.sleep(0.01)
+        return self.to_read.pop(0) if self.to_read else b""
+
+    def close(self):
+        self.closed = True
+
+
+class BridgeTests(unittest.TestCase):
+    def setUp(self):
+        self.cfg = config()
+        self.mqtt = FakeMqtt()
+        self.serial = FakeSerial()
+        self.bridge = Bridge(self.cfg, self.mqtt, lambda *args, **kwargs: self.serial)
+        self.bridge._serial = self.serial
+        self.bridge._on_connect(self.mqtt, None, None, 0, None)
+
+    def deliver(self, payload, topic=None, retain=False):
+        message = SimpleNamespace(topic=topic or self.cfg.obtain_topic,
+                                  payload=payload, retain=retain)
+        self.bridge._on_message(self.mqtt, None, message)
+
+    def test_passthrough_bytes_and_exact_topic(self):
+        self.deliver(command(data="00Feff01"))
+        self.assertEqual(self.serial.written, [bytes.fromhex("00FEFF01")])
+        self.assertEqual(self.mqtt.subscriptions, [(self.cfg.obtain_topic, 0)])
+
+    def test_invalid_messages_never_write(self):
+        bad = [b"not-json", b"[]", command(DataType=True),
+               command(DataType=2049, SystemID="other"),
+               command(UAVType=8), command(data="F"),
+               command(data="FG"), command(data=""),
+               command(data="AA BB"), command(data="AA" * 4097)]
+        for payload in bad:
+            self.deliver(payload)
+        self.deliver(command(), retain=True)
+        self.deliver(command(), topic="/OBTAIN/other")
+        self.assertEqual(self.serial.written, [])
+
+    def test_no_replay_on_disconnect_and_no_unsupported_action(self):
+        self.bridge._on_disconnect(self.mqtt, None, None, 1, None)
+        self.deliver(command())
+        self.assertEqual(self.serial.written, [])
+        self.assertFalse(self.bridge.publish_report(526, "AA", passthrough=True))
+        self.bridge._on_connect(self.mqtt, None, None, 0, None)
+        self.deliver(command(2054, None))
+        self.assertEqual(self.serial.written, [])
+        self.deliver(command())
+        self.assertEqual(self.serial.written, [bytes.fromhex("FE0600FF")])
+
+    def test_extension_handler_and_report(self):
+        seen = []
+        def handler(message, bridge):
+            seen.append(message.data)
+            bridge.publish_report(270, {"Text": "ok", "Cmd": message.data_type, "Code": 0})
+        self.bridge.register_handler(2054, handler)
+        self.deliver(command(2054, None))
+        self.assertEqual(seen, [None])
+        self.assertEqual(self.mqtt.published[0][0], self.cfg.receive_topic)
+        self.assertEqual(self.mqtt.published[0][2:], (0, False))
+
+    def test_serial_chunk_upstream_envelope(self):
+        self.serial.to_read.append(b"\xfe\x00\xff")
+        thread = threading.Thread(target=self.bridge._serial_loop)
+        thread.start()
+        try:
+            deadline = time.monotonic() + 1
+            while not self.mqtt.published and time.monotonic() < deadline:
+                time.sleep(0.01)
+            self.assertEqual(len(self.mqtt.published), 1)
+            topic, payload, qos, retain = self.mqtt.published[0]
+            self.assertEqual((topic, qos, retain), (self.cfg.publish_topic, 0, False))
+            value = json.loads(payload)
+            self.assertEqual(value["DataType"], 526)
+            self.assertEqual(value["Data"], "FE00FF")
+            self.assertEqual(value["UAVType"], 7)
+            self.assertEqual(value["SystemID"], "UAV000001")
+            self.assertIn("+", value["TimeStamp"])
+            self.assertNotIn("DataType ", value)
+        finally:
+            self.bridge._stop.set()
+            thread.join(1)
+
+
+class ValidationTests(unittest.TestCase):
+    def test_envelope_and_hex(self):
+        decoded = decode_command(command(), config())
+        self.assertEqual(decoded.data_type, 526)
+        self.assertEqual(decode_hex(decoded.data, 8), bytes.fromhex("FE0600FF"))
+        self.assertEqual(json.loads(make_report(config(), 526, "AB"))["Data"], "AB")
+
+    def test_config_validation(self):
+        for changes in ({"system_id": "x/#"}, {"uav_type": -1},
+                        {"max_chunk_bytes": 0}, {"mqtt_password_env": "PASS"}):
+            with self.subTest(changes=changes), self.assertRaises(ValueError):
+                config(**changes)
+
+    def test_password_is_read_from_environment(self):
+        cfg = config(mqtt_username="user", mqtt_password_env="BRIDGE_TEST_SECRET")
+        with patch.dict(os.environ, {"BRIDGE_TEST_SECRET": "secret"}):
+            self.assertEqual(cfg.mqtt_password(), "secret")
+        with patch.dict(os.environ, {}, clear=True), self.assertRaises(ValueError):
+            cfg.mqtt_password()
+
+    def test_real_pyserial_loopback(self):
+        cfg = config()
+        mqtt = FakeMqtt()
+        bridge = Bridge(cfg, mqtt, serial.serial_for_url)
+        bridge.start()
+        bridge._on_connect(mqtt, None, None, 0, None)
+        try:
+            deadline = time.monotonic() + 2
+            while bridge._serial is None and time.monotonic() < deadline:
+                time.sleep(0.01)
+            self.assertIsNotNone(bridge._serial)
+            bridge._on_message(mqtt, None, SimpleNamespace(
+                topic=cfg.obtain_topic, payload=command(data="00FEFF"), retain=False))
+            while not mqtt.published and time.monotonic() < deadline:
+                time.sleep(0.01)
+            self.assertEqual(len(mqtt.published), 1)
+            self.assertEqual(json.loads(mqtt.published[0][1])["Data"], "00FEFF")
+        finally:
+            bridge.stop()
+
+    def test_serial_reconnect_discards_old_bytes(self):
+        class BrokenSerial(FakeSerial):
+            def read(self, size):
+                raise OSError("link lost")
+
+        first, second = BrokenSerial(), FakeSerial()
+        second.to_read.append(b"\x02")
+        opened = []
+
+        def factory(*args, **kwargs):
+            opened.append(True)
+            return first if len(opened) == 1 else second
+
+        cfg = config(reconnect_seconds=0.01)
+        mqtt = FakeMqtt()
+        bridge = Bridge(cfg, mqtt, factory)
+        bridge.start()
+        bridge._on_connect(mqtt, None, None, 0, None)
+        try:
+            deadline = time.monotonic() + 1
+            while not mqtt.published and time.monotonic() < deadline:
+                time.sleep(0.01)
+            self.assertGreaterEqual(len(opened), 2)
+            self.assertTrue(first.closed)
+            self.assertEqual(json.loads(mqtt.published[0][1])["Data"], "02")
+        finally:
+            bridge.stop()
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 258 - 0
tests/test_telemetry.py

@@ -0,0 +1,258 @@
+import json
+import math
+import time
+import unittest
+
+from motian_bridge import Bridge, Config
+from motian_bridge.dialects.v20 import VKFly
+from motian_bridge.telemetry import Telemetry
+
+
+def cfg(**kwargs):
+    fields = dict(system_id="UAV000001", uav_type=7, serial_port="loop://",
+                  serial_baudrate=57600, mqtt_host="localhost")
+    fields.update(kwargs)
+    return Config(**fields)
+
+
+def sender(system=1, component=1):
+    return VKFly.MAVLink(None, srcSystem=system, srcComponent=component)
+
+
+def heartbeat(mav, armed=False, mode=4):
+    return mav.heartbeat_encode(
+        VKFly.MAV_TYPE_QUADROTOR, VKFly.MAV_AUTOPILOT_GENERIC,
+        VKFly.MAV_MODE_FLAG_SAFETY_ARMED if armed else 0,
+        mode, VKFly.MAV_STATE_ACTIVE).pack(mav)
+
+
+def bms(mav, id_=0, voltage=22800, current=350, percent=76):
+    return mav.vk_bms_status_encode(
+        1000, voltage, current, 28, percent, id_, 3, 6,
+        [0] * 30, 42, 95).pack(mav)
+
+
+def position(mav):
+    return mav.global_position_int_encode(
+        1000, 312304000, 1214737000, 135500, 120500,
+        520, 110, -30, 8730).pack(mav)
+
+
+class ParserTests(unittest.TestCase):
+    def test_split_frames_crc_and_v1_heartbeat(self):
+        telemetry = Telemetry(cfg())
+        mav = sender()
+        frame = heartbeat(mav, True)
+        self.assertEqual(telemetry.feed(frame[:7], now=10), [])
+        self.assertEqual(telemetry.feed(frame[7:] + position(mav), now=10),
+                         [(274, True)])
+        data = dict(telemetry.tick(now=10))[258]
+        self.assertAlmostEqual(data["Latitude"], 31.2304)
+        self.assertAlmostEqual(data["Altitude"], 120.5)
+        self.assertEqual(data["VelocityZ"], -0.3)
+        self.assertEqual(data["FlightMode"], "POSHOLD")
+
+        corrupted = bytearray(position(mav))
+        corrupted[-1] ^= 0xFF
+        telemetry.feed(corrupted, now=11)
+        # Corrupted position must not replace the good observation.
+        self.assertEqual(telemetry.latest["GLOBAL_POSITION_INT"][1], 10)
+        self.assertEqual(telemetry.feed(heartbeat(mav, False), now=12), [(274, False)])
+
+        mav_v1 = sender()
+        frame_v1 = mav_v1.heartbeat_encode(
+            VKFly.MAV_TYPE_QUADROTOR, VKFly.MAV_AUTOPILOT_GENERIC, 128, 12,
+            VKFly.MAV_STATE_ACTIVE).pack(mav_v1, force_mavlink1=True)
+        self.assertEqual(frame_v1[0], 0xFE)
+        self.assertEqual(Telemetry(cfg()).feed(frame_v1, now=1), [(274, True)])
+
+    def test_flight_units_freshness_and_partial_state(self):
+        telemetry = Telemetry(cfg())
+        mav = sender()
+        telemetry.feed(heartbeat(mav), now=0)
+        initial = dict(telemetry.tick(now=0))[258]
+        self.assertNotIn("Latitude", initial)
+        self.assertEqual(initial["Armed"], False)
+        telemetry.feed(mav.attitude_encode(
+            1000, -0.5, 1.2, -math.pi / 2, 0, 0, 0).pack(mav), now=0.5)
+        telemetry.feed(position(mav), now=0.5)
+        telemetry.feed(mav.vfr_hud_encode(
+            12.4, 13.1, 87, 50, 135.5, 0.8).pack(mav), now=0.5)
+        telemetry.feed(mav.home_position_encode(
+            312280000, 1214700000, 15000, 0, 0, 0, [1, 0, 0, 0],
+            0, 0, 0).pack(mav), now=0.5)
+        telemetry.feed(mav.gps_raw_int_encode(
+            1000000, 3, 312304000, 1214737000, 135500,
+            80, 110, 1300, 8700, 18).pack(mav), now=0.5)
+        self.assertEqual(telemetry.tick(now=0.9), [])
+        state = dict(telemetry.tick(now=1))[258]
+        self.assertAlmostEqual(state["Roll"], math.degrees(-0.5), places=4)
+        self.assertAlmostEqual(state["Pitch"], math.degrees(1.2), places=4)
+        self.assertAlmostEqual(state["Yaw"], 270, places=4)
+        self.assertAlmostEqual(state["GroundSpeed"], 13.1, places=5)
+        self.assertEqual(state["HomeAltitude"], 15)
+        self.assertGreater(state["SpaceDistToHome"], state["DistanceToHome"])
+        self.assertEqual(state["Gps"]["SatelliteCount"], 18)
+        self.assertEqual(state["Gps"]["LevelDivisor"], 0.8)
+        self.assertNotIn("Temperature", state)
+        self.assertNotIn("FlyTime", state)
+        self.assertNotIn("IsMotorsOn", state)
+
+        # Heartbeat is fresh, but all other observations have expired.
+        telemetry.feed(heartbeat(mav), now=5.6)
+        stale = dict(telemetry.tick(now=5.6))[258]
+        self.assertNotIn("Latitude", stale)
+        self.assertNotIn("Gps", stale)
+        self.assertNotIn("HomeLatitude", stale)
+        self.assertEqual(telemetry.tick(now=11), [])
+        self.assertEqual(telemetry.feed(heartbeat(mav), now=11), [])
+        self.assertEqual(dict(telemetry.tick(now=11))[258]["FlightMode"], "POSHOLD")
+
+    def test_lock_to_first_flight_controller_and_reset(self):
+        telemetry = Telemetry(cfg())
+        gcs = sender(10, 190)
+        foreign = sender(2, 1)
+        selected = sender(1, 1)
+        telemetry.feed(heartbeat(gcs), now=0)
+        self.assertIsNone(telemetry.source)
+        telemetry.feed(heartbeat(selected, True), now=0)
+        telemetry.feed(heartbeat(foreign, False), now=0)
+        telemetry.feed(position(foreign), now=0)
+        self.assertEqual(telemetry.source, (1, 1))
+        self.assertEqual(dict(telemetry.tick(now=0))[258]["Armed"], True)
+        self.assertNotIn("Latitude", dict(telemetry.tick(now=1))[258])
+        replacement = Telemetry(cfg())
+        self.assertEqual(replacement.feed(heartbeat(foreign), now=2), [(274, False)])
+        self.assertEqual(replacement.source, (2, 1))
+
+    def test_bms_only_two_packs_rate_limit_and_stale(self):
+        telemetry = Telemetry(cfg())
+        mav = sender()
+        telemetry.feed(heartbeat(mav), now=0)
+        telemetry.feed(bms(mav, 0), now=0)
+        single = dict(telemetry.tick(now=0))[257]
+        self.assertEqual(single["Battery_VoltageLeft"], 22.8)
+        self.assertEqual(single["Battery_Current"], 3.5)
+        self.assertEqual(single["Battery_Temperature"], 28)
+        self.assertEqual(single["cycleIndex"], 42)
+        self.assertEqual(single["flags"], 3)
+        telemetry.feed(bms(mav, 1, voltage=23000, current=-120, percent=50), now=0.2)
+        self.assertEqual(telemetry.tick(now=0.2), [])
+        dual = dict(telemetry.tick(now=1))[257]
+        self.assertEqual(dual["Battery_VoltageRight"], 23)
+        self.assertEqual(dual["Battery_CurrentRight"], -1.2)
+        self.assertNotIn("Battery_Voltage", dual)
+        self.assertNotIn("Battery_Remaining", dual)
+        telemetry.feed(bms(mav, 7), now=2)
+        self.assertNotIn(257, dict(telemetry.tick(now=2)))
+        telemetry.feed(bms(mav, 1, percent=90), now=6)
+        expired = dict(telemetry.tick(now=6))[257]
+        self.assertNotIn("Battery_VoltageLeft", expired)
+        self.assertEqual(expired["Battery_RemainingRight"], 90)
+
+    def test_statustext_immediate_and_packs_without_heartbeat_ignored(self):
+        telemetry = Telemetry(cfg())
+        mav = sender()
+        self.assertEqual(telemetry.feed(bms(mav), now=0), [])
+        self.assertEqual(telemetry.tick(now=0), [])
+        telemetry.feed(heartbeat(mav), now=1)
+        status = mav.statustext_encode(4, "电池低".encode("utf-8")).pack(mav)
+        self.assertEqual(telemetry.feed(status, now=1),
+                         [(260, {"severity": 4, "msg": "电池低"})])
+
+    def test_nonfinite_floats_are_omitted_from_json(self):
+        telemetry = Telemetry(cfg())
+        mav = sender()
+        telemetry.feed(heartbeat(mav), now=0)
+        telemetry.feed(mav.attitude_encode(
+            1000, float("nan"), 0.25, float("inf"), 0, 0, 0).pack(mav), now=0)
+        telemetry.feed(mav.vfr_hud_encode(
+            float("nan"), 5, 0, 0, 0, float("inf")).pack(mav), now=0)
+        report = dict(telemetry.tick(now=0))[258]
+        self.assertNotIn("Roll", report)
+        self.assertNotIn("Yaw", report)
+        self.assertNotIn("AirSpeed", report)
+        self.assertNotIn("ClimbRate", report)
+        self.assertAlmostEqual(report["Pitch"], math.degrees(0.25), places=4)
+        self.assertEqual(report["GroundSpeed"], 5)
+        json.dumps(report, allow_nan=False)
+
+
+class BridgeTelemetryTests(unittest.TestCase):
+    def test_bridge_reports_and_raw_switch(self):
+        from test_bridge import FakeMqtt, FakeSerial
+
+        for raw in (True, False):
+            with self.subTest(raw=raw):
+                conf = cfg(publish_raw_mavlink=raw)
+                mqtt = FakeMqtt()
+                fake_serial = FakeSerial()
+                mav = sender()
+                fake_serial.to_read.append(heartbeat(mav, True) + position(mav) + bms(mav))
+                bridge = Bridge(conf, mqtt, lambda *args, **kwargs: fake_serial)
+                bridge.start()
+                bridge._on_connect(mqtt, None, None, 0, None)
+                try:
+                    deadline = time.monotonic() + 2
+                    while len(mqtt.published) < (4 if raw else 3) and time.monotonic() < deadline:
+                        time.sleep(0.01)
+                    by_type = {json.loads(payload)["DataType"]: (topic, json.loads(payload)["Data"])
+                               for topic, payload, _, _ in mqtt.published}
+                    self.assertEqual(526 in by_type, raw)
+                    self.assertEqual(by_type[274], (conf.receive_topic, True))
+                    self.assertEqual(by_type[257][0], conf.receive_topic)
+                    self.assertEqual(by_type[258][1]["Latitude"], 31.2304)
+                    self.assertEqual(by_type[258][0], conf.receive_topic)
+                    # Turning off upstream raw frames does not affect #526 downlink.
+                    bridge._on_message(mqtt, None, type("Command", (), {
+                        "topic": conf.obtain_topic,
+                        "retain": False,
+                        "payload": json.dumps({
+                            "DataType": 526, "UAVType": conf.uav_type,
+                            "SystemID": conf.system_id, "TimeStamp": "2026-09-29T00:00:00+08:00",
+                            "Data": "01FF",
+                        }).encode(),
+                    })())
+                    self.assertEqual(fake_serial.written[-1], b"\x01\xff")
+                finally:
+                    bridge.stop()
+
+    def test_serial_reconnect_rebinds_flight_controller(self):
+        from test_bridge import FakeMqtt, FakeSerial
+
+        class DisconnectedSerial(FakeSerial):
+            def read(self, size):
+                if self.to_read:
+                    return self.to_read.pop(0)
+                raise OSError("cable disconnected")
+
+        first = DisconnectedSerial()
+        first.to_read.append(heartbeat(sender(1), True))
+        second = FakeSerial()
+        second.to_read.append(heartbeat(sender(2), False))
+        opened = []
+
+        def factory(*args, **kwargs):
+            opened.append(True)
+            return first if len(opened) == 1 else second
+
+        conf = cfg(publish_raw_mavlink=False, reconnect_seconds=0.01)
+        mqtt = FakeMqtt()
+        bridge = Bridge(conf, mqtt, factory)
+        bridge._on_connect(mqtt, None, None, 0, None)
+        bridge.start()
+        try:
+            deadline = time.monotonic() + 2
+            while bridge._telemetry.source != (2, 1) and time.monotonic() < deadline:
+                time.sleep(0.01)
+            self.assertTrue(first.closed)
+            self.assertEqual(bridge._telemetry.source, (2, 1))
+            armed_events = [json.loads(payload)["Data"] for topic, payload, _, _ in mqtt.published
+                            if json.loads(payload)["DataType"] == 274]
+            self.assertEqual(armed_events, [True, False])
+        finally:
+            bridge.stop()
+
+
+if __name__ == "__main__":
+    unittest.main()

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini