2 コミット 1715e7887b ... 04256529a8

作者 SHA1 メッセージ 日付
  LiuYang 04256529a8 Add emergency motor stop command forwarding 1 週間 前
  LiuYang 679bef9fad Add per-aircraft state object and automatic parameter sync 3 週間 前

+ 88 - 0
AGENTS.md

@@ -0,0 +1,88 @@
+# AGENTS.md
+
+本项目是**摩天云 MQTT–MAVLink 串口桥**(`motian-mavlink-bridge`)。它连接飞控串口与摩天云 MQTT Broker:被动解析串口上的 MAVLink 帧,按平台 Base JSON 协议上报遥测,并把平台下行指令翻译成 VKFly MAVLink 帧写回串口。详细协议说明见 `README.md`,实施方案见 `doc/实施方案.md`。
+
+## 技术栈与运行环境
+
+- Python **>= 3.10**(代码使用 `X | None` 等语法),Linux 部署。
+- 运行依赖:`paho-mqtt>=2,<3`、`pyserial>=3.5,<4`、`pymavlink>=2.4.50,<3`(见 `pyproject.toml`)。
+- MAVLink 方言 `VKFly` 由 `msg_definitions` Git 子模块的 XML 生成。
+- 测试用标准库 `unittest`,不使用 pytest。
+
+## 目录结构
+
+```
+motian_bridge/
+  cli.py          生产入口,创建 paho-mqtt / pyserial 实例并注入 Bridge
+  bridge.py       Bridge 核心:MQTT 收发、串口读写线程、Base JSON 编解码、handler 注册、参数表自动同步
+  config.py       Config 冻结 dataclass,含校验;密钥从环境变量读取
+  aircraft.py     Aircraft:按飞控聚合身份、最新观测、电池与参数表(ParamTable)
+  telemetry.py    MAVLink 帧解析与上报节流,内部持有一个 Aircraft(仅串口线程使用)
+  commands.py     平台业务指令 -> VKFly MAVLink 帧的 handler
+  dialects/v20/VKFly.py   ⚠️ 生成文件,禁止手工修改
+tests/            unittest 用例(依赖 FakeMqtt/FakeSerial,test_commands 复用 test_bridge 的辅助函数)
+tools/            local_mqtt_broker.py,本地 aMQTT 测试 Broker(非生产依赖)
+doc/              平台接口规范、实施方案、qa.md(待平台澄清问题)
+config.example.json   配置模板
+```
+
+## 环境搭建与常用命令
+
+```bash
+python3 -m venv .venv
+.venv/bin/python -m pip install -e .          # 安装(可编辑模式)
+cp config.example.json config.local.json      # 再按需修改真实参数
+
+# 跑全部测试(必须是 -s tests,用例间有相互导入)
+.venv/bin/python -m unittest discover -s tests -v
+
+# 启动桥接服务
+export MOTIAN_MQTT_PASSWORD='由部署环境提供'
+.venv/bin/motian-mavlink-bridge --config config.local.json --debug
+
+# 本地联调:先起本地 Broker(需先 pip install "amqtt>=0.12,<0.13")
+.venv/bin/python tools/local_mqtt_broker.py
+```
+
+## 架构与代码约定
+
+- **依赖注入**:`Bridge` 只接收抽象的 MQTT client 与 `serial_factory`,具体实现由 `cli.py` 提供,便于测试用 fake 替换。保持这一模式。
+- **线程边界**:串口读取线程(`_serial_loop`)独占 `Telemetry` 解析器与其内部的 `Aircraft` 状态;MQTT 回调线程负责下发。跨线程共享用 `threading.Lock` / `threading.Event`,不要引入全局可变状态。`Aircraft` 的 `latest`/`batteries`/`params` 都只允许串口线程写入,`Telemetry` 保留 `source`/`latest`/`params`/`batteries` 兼容属性。
+- **配置**:所有配置项加到 `Config` 冻结 dataclass,并在 `__post_init__` 里做校验(非法值直接抛 `ValueError`)。**密钥、密码只从环境变量读取**(`mqtt_password_env`),绝不写进 JSON 或代码。
+- **风格**:4 空格缩进、双引号、类型标注、模块级 `LOG = logging.getLogger(__name__)`。判断类型用 `type(x) is int` 这类精确判断(避免 `bool` 被当作 `int` 接受)。
+- **扩展新平台指令**:在 `FlightCommandHandlers.install()` 里 `register_handler(data_type, handler)`,handler 返回时通过 `bridge.publish_report(data_type, data)` 上报。**未实现/未确认的指令只记日志并丢弃,绝不伪造成功 ACK**。
+- 文档、注释用中文;代码标识符和 **git 提交信息用英文祈使句**(如 `Add platform command forwarding callbacks`)。
+
+## 协议约定与红线
+
+- 上行原始透传固定为 `DataType=526` + 大写 HEX `Data`,发到 `/PUBLISH/<SystemID>`;结构化上报发到 `/RECEIVE/<SystemID>`。字段名是 `DataType`(**没有尾随空格**)。
+- MQTT 使用 **QoS 0、不 retain**;收到的 retained 下行一律丢弃;断线不缓存、不重放。
+- 下行报文必须有 `DataType`/`UAVType`/`Data`/`SystemID`/`TimeStamp`,且设备标识须与本机配置一致;`Data` 必须是非空、偶数长度 HEX,不超过 `max_command_bytes`。
+- 业务指令在**尚未收到飞控 HEARTBEAT、未锁定目标 system/component 前禁止下发**(`_target()` 会抛错保护)。
+- **不要修改** `motian_bridge/dialects/v20/VKFly.py`——它是生成文件。XML 变更后重新生成:
+
+  ```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
+  ```
+
+- `Telemetry` 中 `VKFly.MAVLINK_IGNORE_CRC = 0` 是安全底线,**不要**为调试方便关闭 CRC 校验。
+- 不要提交 `config.local.json`、`.venv/`、`*.pdf`(见 `.gitignore`);
+- 不要降低 TLS 强度(`mqtt_ca_file` 校验、证书配置等保持开启)。
+
+## 测试要求
+
+- 新增行为必须补 `tests/` 下的 `unittest` 用例;提交前必须通过:
+  `.venv/bin/python -m unittest discover -s tests -v`
+- 涉及串口/MQTT 的用例用 `FakeMqtt` / `FakeSerial`(见 `tests/test_bridge.py`);涉及真实帧编解码时用 `VKFly.MAVLink` 构造帧,参照 `tests/test_telemetry.py`。
+- 涉及配置校验的改动,同步更新 `ValidationTests`。
+
+## 常见陷阱
+
+- 串口**重连会重置 `Telemetry`**(可能换了一架飞控),旧状态必须丢弃,不要跨重连复用遥测。
+- `Telemetry` 逐字节喂给 `parse_char`,每次只返回一条消息;分块读取不影响帧边界,不要改成整块喂入。
+- `STATUSTEXT` 的文本需绕过生成的 Python 解码器、手动按 `char[50]` 读 UTF-8(生成代码按 ASCII 处理会丢字符),改动这里前先看 `telemetry.py` 注释。
+- 除**锁定 HEARTBEAT 后主动同步一次参数表**外,桥接程序只被动接收,不主动向飞控请求消息流;缺字段时先确认飞控是否在广播对应消息(`--debug` 可看到 missing/stale 日志)。参数同步由 `request_params_on_start`(默认开)控制,超时/重试见 `param_request_timeout`、`param_request_max_attempts`;`#281` 与自动同步共用 `commands.py` 的 `request_params()`,改动需同步更新 `test_commands.ParameterSyncTests`。
+- `#274` 的 `true` 表示**解锁**,不是物理离地;`DataType 258` 的 `IsFlying` 也按解锁解释。
+- 部分平台参数映射尚未由飞控协议确认(`commands.py` 的 `TODO_PARAMS`)以及 `#2050 Data=true` 的“接管”语义,均保留 TODO,**不要擅自映射或发送**;需先澄清可记入 `doc/qa.md`。

+ 13 - 4
README.md

@@ -13,7 +13,11 @@
 | 260 提示 | STATUSTEXT | 收到就上报,保留原始严重级别和 UTF-8 文本 |
 | 274 飞行状态 | HEARTBEAT 解锁标志 | 首次获知及改变时上报布尔值;**true=解锁、false=上锁,并非实际离地/着陆** |
 
-只解析首个有效飞控 HEARTBEAT 对应的 system/component ID。串口重连重选来源并清空缓存;心跳超过 5 秒暂停 #258,其他来源字段超过 5 秒自动剔除。只被动接收,不请求飞控遥测;如缺少字段,请先确认飞控串口是否广播对应消息。缺失消息类型可用 `--debug` 查看。`#257` 在双电池时不假定并联/串联或容量一致,不填写推算的总电压/电量。
+只解析首个有效飞控 HEARTBEAT 对应的 system/component ID。串口重连重选来源并清空缓存;心跳超过 5 秒暂停 #258,其他来源字段超过 5 秒自动剔除。除下面所述的参数表自动同步外,只被动接收,不请求飞控遥测;如缺少字段,请先确认飞控串口是否广播对应消息。缺失消息类型可用 `--debug` 查看。`#257` 在双电池时不假定并联/串联或容量一致,不填写推算的总电压/电量。
+
+## 参数表自动同步
+
+锁定飞控 HEARTBEAT 后,默认会主动发送一次 `PARAM_REQUEST_LIST`,把飞控回传的 `PARAM_VALUE` 累积到本机该飞机的参数表(仅存内存,不上报、不落盘)。`param_request_timeout` 秒内未收齐时按 `param_request_max_attempts` 重试(默认 15 秒、最多 3 次),收齐或放弃后停止。串口重连视为换了飞控,会重新同步一次。将 `request_params_on_start` 设为 `false` 可关闭该行为,回到完全被动接收。`#282` 中需要读取现值的参数(如 `FS_CONF_A`)会用到这张表;平台下发的 `#281` 复用同一套请求逻辑,不受该开关影响。
 
 ## 安装和运行
 
@@ -44,6 +48,10 @@ Bridge 在收到飞控 HEARTBEAT 并锁定目标 system/component 后,支持
 | 2054 | 无人机起飞 | `COMMAND_LONG / MAV_CMD_NAV_TAKEOFF`,高度 `NaN` 表示使用飞控默认值 |
 | 2055 | 无人机降落 | `COMMAND_LONG / MAV_CMD_NAV_LAND` |
 | 2070 | 指点飞行 | `COMMAND_INT / MAV_CMD_DO_REPOSITION`,相对高度坐标系 |
+| 2073 | 高空紧急停桨(停桨后开伞) | `COMMAND_LONG / MAV_CMD_DO_PARACHUTE`,`param1=PARACHUTE_RELEASE`("Release parachute and kill motors") |
+| 2074 | 地面紧急停桨(停桨后不开伞) | `COMMAND_LONG / MAV_CMD_COMPONENT_ARM_DISARM`,`param1=0`(上锁)、`param2=21196`(强制),不触发开伞 |
+
+`#2073` 的映射采用飞控通用的 `MAV_CMD_DO_PARACHUTE`/`PARACHUTE_RELEASE`(开伞并停桨);`#2074` 采用强制上锁 `MAV_CMD_COMPONENT_ARM_DISARM`(`param2=21196`)停桨而不开伞,避免 `MAV_CMD_DO_FLIGHTTERMINATION` 在多旋翼上可能触发的开伞。若 VKFly 有专用时序命令,需与飞控方确认后调整。
 
 #282 已确认的参数映射:
 
@@ -60,11 +68,11 @@ Bridge 在收到飞控 HEARTBEAT 并锁定目标 system/component 后,支持
 | `obstacle_avoidance_distance` | `OBAVOID_DIST` |
 | `return_type` | `RTL_EXEC_MODE`,直线/正序/逆序分别为 0/1/2 |
 | `precision_land_mode` | `RTL_PLD_EN`,关闭/二维码引导分别为 0/1 |
-| `low_battery_action` | 修改 `FS_CONF_A` 的低两级电量保护位;必须先执行 #281 并收到当前 `FS_CONF_A`,以保留其他保护位 |
+| `low_battery_action` | 修改 `FS_CONF_A` 的低两级电量保护位;需先获得当前 `FS_CONF_A`(参数表自动同步或执行 #281 后均可),以保留其他保护位 |
 
 以下参数映射尚未由飞控协议明确,代码仅记录 TODO 并拒绝写串口:`battery_emergency_threshold`、`geofence_action`、`visual_sensor_enable`、`april_tag_id`、`data_link_loss_action`、`rc_loss_action`、`parachute_safety_enable`、`four_g_lost_action`、`rtk_switch`。未知参数同样不会直接作为 param_id 发送。
 
-这些业务指令不会在尚未收到飞控 HEARTBEAT 时发送,以避免使用错误的目标 system/component。当前只负责下发帧;COMMAND_ACK、PARAM_VALUE 到平台业务应答的关联与超时重试尚未实现。
+这些业务指令不会在尚未收到飞控 HEARTBEAT 时发送,以避免使用错误的目标 system/component。当前只负责下发帧;COMMAND_ACK 到平台业务应答的关联与超时重试尚未实现。`PARAM_VALUE` 会缓存在本机飞机对象的参数表中,但不向平台转发。
 
 ## 没有远程平台时的串口实测
 
@@ -119,7 +127,8 @@ cp config.example.json config.local.json
   "mqtt_username": null,
   "mqtt_password_env": null,
   "mqtt_tls": false,
-  "publish_raw_mavlink": true
+  "publish_raw_mavlink": true,
+  "request_params_on_start": true
 }
 ```
 

+ 4 - 1
config.example.json

@@ -16,5 +16,8 @@
   "max_chunk_bytes": 1024,
   "max_command_bytes": 4096,
   "reconnect_seconds": 2.0,
-  "publish_raw_mavlink": true
+  "publish_raw_mavlink": true,
+  "request_params_on_start": true,
+  "param_request_timeout": 15.0,
+  "param_request_max_attempts": 3
 }

+ 229 - 0
motian_bridge/aircraft.py

@@ -0,0 +1,229 @@
+"""Per-flight-controller state grouped into a single aircraft object.
+
+An :class:`Aircraft` is created only after the first valid flight-controller
+HEARTBEAT is locked by :class:`~motian_bridge.telemetry.Telemetry`. It owns the
+identity (system/component), the latest raw observations, the vendor battery
+packs and the downloaded parameter table. A serial reconnect discards the whole
+object, exactly like :class:`Telemetry` itself.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import math
+from typing import Any
+
+from .config import Config
+from .dialects.v20 import VKFly
+
+
+# Observations older than this are never used as current flight state.
+STALE_SECONDS = 5.0
+# VKFly does not define a parameter index for unknown entries.
+UNKNOWN_PARAM_INDEX = 0xFFFF
+
+
+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
+
+
+@dataclass
+class ParamEntry:
+    """One downloaded parameter, with the metadata VKFly reports."""
+
+    value: float
+    param_type: int | None = None
+    index: int | None = None
+
+
+class ParamTable:
+    """The full parameter table of one aircraft, filled from PARAM_VALUE frames."""
+
+    def __init__(self) -> None:
+        self.entries: dict[str, ParamEntry] = {}
+        # param_count from the flight controller, once it has been seen.
+        self.count: int | None = None
+        # param_index values already received, used to detect a complete download.
+        self.received: set[int] = set()
+
+    def update(self, message: Any) -> None:
+        """Record one PARAM_VALUE message."""
+        param_id = message.param_id
+        if isinstance(param_id, bytes):
+            param_id = param_id.decode("ascii", "replace")
+        param_id = param_id.rstrip("\x00")
+        index = int(message.param_index)
+        self.entries[param_id] = ParamEntry(
+            value=float(message.param_value),
+            param_type=int(message.param_type),
+            index=index,
+        )
+        count = int(message.param_count)
+        if count > 0:
+            self.count = count
+        if index != UNKNOWN_PARAM_INDEX:
+            self.received.add(index)
+
+    def __setitem__(self, param_id: str, value: float | ParamEntry) -> None:
+        entry = value if isinstance(value, ParamEntry) else ParamEntry(float(value))
+        self.entries[param_id] = entry
+
+    def __contains__(self, param_id: object) -> bool:
+        return param_id in self.entries
+
+    def __len__(self) -> int:
+        return len(self.entries)
+
+    def get(self, param_id: str, default: float | None = None) -> float | None:
+        entry = self.entries.get(param_id)
+        return entry.value if entry is not None else default
+
+    def complete(self) -> bool:
+        """True once every indexed parameter has been received."""
+        return self.count is not None and self.count > 0 and len(self.received) >= self.count
+
+
+class Aircraft:
+    """State that belongs to a single flight controller."""
+
+    def __init__(self, config: Config, system: int, component: int,
+                 autopilot: int, uav_type: int) -> None:
+        self.config = config
+        self.system = system
+        self.component = component
+        self.autopilot = autopilot
+        self.uav_type = uav_type
+        self.latest: dict[str, tuple[Any, float]] = {}
+        self.batteries: dict[int, tuple[Any, float]] = {}
+        self.params = ParamTable()
+        # Automatic parameter download bookkeeping.
+        self.param_sync_requested = False
+        self.param_sync_at: float | None = None
+        self.param_request_attempts = 0
+
+    @property
+    def source(self) -> tuple[int, int]:
+        return (self.system, self.component)
+
+    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 param_value(self, param_id: str) -> float | None:
+        return self.params.get(param_id)
+
+    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 & VKFly.MAV_MODE_FLAG_SAFETY_ARMED),
+            "IsFlying": bool(heartbeat.base_mode & VKFly.MAV_MODE_FLAG_SAFETY_ARMED),
+            "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

+ 34 - 0
motian_bridge/bridge.py

@@ -6,6 +6,7 @@ import json
 import logging
 import re
 import threading
+import time
 from typing import Any, Callable
 
 from .config import Config
@@ -249,5 +250,38 @@ class Bridge:
                         self.publish_report(data_type, report)
                 for data_type, report in self._telemetry.tick():
                     self.publish_report(data_type, report)
+                self._sync_parameters(time.monotonic())
             except Exception:
                 LOG.exception("telemetry processing failed; serial link preserved")
+
+    def _sync_parameters(self, now: float) -> None:
+        """Download the full parameter table once after locking a flight controller."""
+        if not self.config.request_params_on_start:
+            return
+        aircraft = self._telemetry.aircraft
+        if aircraft is None:
+            return
+        if not aircraft.param_sync_requested:
+            aircraft.param_sync_requested = True
+            aircraft.param_sync_at = now
+            aircraft.param_request_attempts = 1
+            LOG.info("requesting full parameter table from sys=%s comp=%s", *aircraft.source)
+            self._flight_commands.request_params(*aircraft.source)
+            return
+        if aircraft.params.complete():
+            return
+        if aircraft.param_request_attempts >= self.config.param_request_max_attempts:
+            return
+        last_request = aircraft.param_sync_at if aircraft.param_sync_at is not None else now
+        if now - last_request < self.config.param_request_timeout:
+            return
+        aircraft.param_sync_at = now
+        aircraft.param_request_attempts += 1
+        LOG.warning(
+            "parameter table incomplete (%d/%s); re-requesting (%d/%d)",
+            len(aircraft.params.received),
+            aircraft.params.count if aircraft.params.count is not None else "?",
+            aircraft.param_request_attempts,
+            self.config.param_request_max_attempts,
+        )
+        self._flight_commands.request_params(*aircraft.source)

+ 30 - 0
motian_bridge/commands.py

@@ -93,6 +93,8 @@ class FlightCommandHandlers:
         self.bridge.register_handler(2054, self.takeoff)
         self.bridge.register_handler(2055, self.land)
         self.bridge.register_handler(2070, self.reposition)
+        self.bridge.register_handler(2073, self.emergency_stop_with_parachute)
+        self.bridge.register_handler(2074, self.emergency_stop_motors)
 
     def _target(self) -> tuple[int, int]:
         source = self.bridge.mavlink_target
@@ -107,6 +109,10 @@ class FlightCommandHandlers:
         if command.data is not None:
             raise ValueError("DataType 281 Data must be null")
         system, component = self._target()
+        self.request_params(system, component)
+
+    def request_params(self, system: int, component: int) -> None:
+        """Send PARAM_REQUEST_LIST to download the flight controller's table."""
         self._send(self.encoder.param_request_list_encode(system, component))
 
     def set_param(self, command: BaseMessage, bridge: Bridge) -> None:
@@ -173,6 +179,18 @@ class FlightCommandHandlers:
             1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
         ))
 
+    def emergency_stop_with_parachute(self, command: BaseMessage, bridge: Bridge) -> None:
+        """DataType 2073: high-altitude emergency motor stop, then parachute release."""
+        if command.data is not None:
+            raise ValueError("DataType 2073 Data must be null")
+        system, component = self._target()
+        # VKFly MAV_CMD_DO_PARACHUTE with PARACHUTE_RELEASE means
+        # "Release parachute and kill motors", i.e. stop propellers and deploy.
+        self._send(self.encoder.command_long_encode(
+            system, component, VKFly.MAV_CMD_DO_PARACHUTE, 0,
+            float(VKFly.PARACHUTE_RELEASE), 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
+        ))
+
     def takeoff(self, command: BaseMessage, bridge: Bridge) -> None:
         if command.data is not None:
             raise ValueError("DataType 2054 Data must be null")
@@ -183,6 +201,18 @@ class FlightCommandHandlers:
             raise ValueError("DataType 2055 Data must be null")
         self._send_command_long(VKFly.MAV_CMD_NAV_LAND)
 
+    def emergency_stop_motors(self, command: BaseMessage, bridge: Bridge) -> None:
+        """DataType 2074: ground emergency motor stop without parachute."""
+        if command.data is not None:
+            raise ValueError("DataType 2074 Data must be null")
+        system, component = self._target()
+        # Forced disarm (MAV_CMD_COMPONENT_ARM_DISARM param2=21196) kills the
+        # motors without triggering the parachute that DataType 2073 uses.
+        self._send(self.encoder.command_long_encode(
+            system, component, VKFly.MAV_CMD_COMPONENT_ARM_DISARM, 0,
+            0.0, 21196.0, 0.0, 0.0, 0.0, 0.0, 0.0,
+        ))
+
     def _send_command_long(self, mav_command: int, *, param7: float = math.nan) -> None:
         system, component = self._target()
         nan = math.nan

+ 9 - 0
motian_bridge/config.py

@@ -26,6 +26,9 @@ class Config:
     max_command_bytes: int = 4096
     reconnect_seconds: float = 2.0
     publish_raw_mavlink: bool = True
+    request_params_on_start: bool = True
+    param_request_timeout: float = 15.0
+    param_request_max_attempts: int = 3
 
     def __post_init__(self) -> None:
         if not self.system_id or any(c in self.system_id for c in "/+#\x00"):
@@ -42,6 +45,12 @@ class Config:
             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 type(self.request_params_on_start) is not bool:
+            raise ValueError("request_params_on_start must be a boolean")
+        if self.param_request_timeout <= 0:
+            raise ValueError("param_request_timeout must be positive")
+        if type(self.param_request_max_attempts) is not int or self.param_request_max_attempts < 1:
+            raise ValueError("param_request_max_attempts must be a positive integer")
         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:

+ 51 - 149
motian_bridge/telemetry.py

@@ -1,14 +1,16 @@
 """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.
+Observations are grouped per flight controller in an
+:class:`~motian_bridge.aircraft.Aircraft`. Times below are monotonic receive
+times, never the flight controller's boot clock.
 """
 
 import logging
-import math
 import time
 from typing import Any
 
+from .aircraft import Aircraft, ParamTable
 from .config import Config
 from .dialects.v20 import VKFly
 
@@ -17,51 +19,52 @@ 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.
+    Neither the parser nor the current :class:`Aircraft` 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.params: dict[str, float] = {}
+        self.aircraft: Aircraft | None = None
+        self._empty_params = ParamTable()
         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
 
+    # Backwards-compatible views onto the currently locked aircraft. These are
+    # empty until the first valid flight-controller HEARTBEAT arrives.
+    @property
+    def source(self) -> tuple[int, int] | None:
+        return self.aircraft.source if self.aircraft is not None else None
+
+    @property
+    def latest(self) -> dict[str, tuple[Any, float]]:
+        return self.aircraft.latest if self.aircraft is not None else {}
+
+    @property
+    def batteries(self) -> dict[int, tuple[Any, float]]:
+        return self.aircraft.batteries if self.aircraft is not None else {}
+
+    @property
+    def params(self) -> ParamTable:
+        return self.aircraft.params if self.aircraft is not None else self._empty_params
+
+    def param_value(self, param_id: str) -> float | None:
+        return self.params.get(param_id)
+
     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]] = []
@@ -77,25 +80,30 @@ class Telemetry:
                 reports.extend(self._handle(message, now))
         return reports
 
+    def _lock(self, message: Any, src: tuple[int, int]) -> None:
+        self.aircraft = Aircraft(
+            self.config, src[0], src[1], int(message.autopilot), int(message.type)
+        )
+        LOG.info("locked MAVLink flight controller sys=%s comp=%s", *src)
+
     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 self.aircraft 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:
+            self._lock(message, src)
+        if src != self.aircraft.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)
+        self.aircraft.latest[kind] = (message, now)
         if kind == "HEARTBEAT":
             armed = bool(message.base_mode & _ARMED_FLAG)
             if armed != self.last_armed:
@@ -103,7 +111,7 @@ class Telemetry:
                 # 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.aircraft.batteries[message.bat_id] = (message, now)
             self.battery_dirty = True
         elif kind == "STATUSTEXT":
             # Generated Python text decoding assumes ASCII and irreversibly
@@ -117,39 +125,35 @@ class Telemetry:
             if text:
                 return [(260, {"severity": int(message.severity), "msg": text})]
         elif kind == "PARAM_VALUE":
-            param_id = message.param_id
-            if isinstance(param_id, bytes):
-                param_id = param_id.decode("ascii", "replace")
-            self.params[param_id.rstrip("\x00")] = float(message.param_value)
+            self.aircraft.params.update(message)
         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)
+        aircraft = self.aircraft
+        if aircraft is None:
+            return reports
+        heartbeat = aircraft.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)))
+            reports.append((258, aircraft.flight_data(now, heartbeat)))
             self.last_flight_report = now
-            self._log_missing(now)
+            self._log_missing(aircraft, 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)
+            battery = aircraft.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:
+    def _log_missing(self, aircraft: Aircraft, now: float) -> None:
         if self.last_missing_log is not None and now - self.last_missing_log < 60:
             return
         missing = [
@@ -162,110 +166,8 @@ class Telemetry:
                 "HOME_POSITION",
                 "VK_BMS_STATUS",
             )
-            if self._fresh(name, now) is None
+            if aircraft.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

+ 85 - 0
tests/test_aircraft.py

@@ -0,0 +1,85 @@
+from types import SimpleNamespace
+import unittest
+
+from motian_bridge import Config
+from motian_bridge.aircraft import ParamEntry, ParamTable
+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 param_value_frame(mav, param_id, value, index=0, count=1,
+                      type_=VKFly.MAV_PARAM_TYPE_REAL32):
+    return mav.param_value_encode(param_id, value, type_, count, index).pack(mav)
+
+
+class AircraftTests(unittest.TestCase):
+    def test_aircraft_created_from_heartbeat(self):
+        telemetry = Telemetry(cfg())
+        self.assertIsNone(telemetry.aircraft)
+        self.assertIsNone(telemetry.source)
+        telemetry.feed(heartbeat(sender(4, 1)), now=0)
+        aircraft = telemetry.aircraft
+        self.assertIsNotNone(aircraft)
+        self.assertEqual(aircraft.source, (4, 1))
+        self.assertEqual(telemetry.source, (4, 1))
+        self.assertEqual(aircraft.uav_type, VKFly.MAV_TYPE_QUADROTOR)
+
+    def test_param_table_accumulates_and_completes(self):
+        telemetry = Telemetry(cfg())
+        mav = sender()
+        telemetry.feed(heartbeat(mav), now=0)
+        table = telemetry.aircraft.params
+        self.assertFalse(table.complete())
+        telemetry.feed(param_value_frame(mav, b"FS_CONF_A", 4660, index=0, count=2), now=0)
+        self.assertFalse(table.complete())
+        telemetry.feed(param_value_frame(mav, b"ALT_LIM_UP1", 120, index=1, count=2), now=0)
+        self.assertTrue(table.complete())
+        self.assertEqual(table.get("FS_CONF_A"), 4660)
+        self.assertEqual(table.get("ALT_LIM_UP1"), 120)
+        self.assertEqual(telemetry.param_value("FS_CONF_A"), 4660)
+        self.assertIsNone(telemetry.param_value("MISSING"))
+        self.assertIn("FS_CONF_A", table)
+
+    def test_duplicate_index_and_unknown_index_do_not_complete(self):
+        table = ParamTable()
+        known = SimpleNamespace(param_id=b"P", param_value=1.0, param_type=9,
+                                param_count=2, param_index=0)
+        table.update(known)
+        table.update(known)
+        self.assertEqual(len(table.received), 1)
+        self.assertEqual(len(table), 1)
+        unknown = SimpleNamespace(param_id=b"Q", param_value=2.0, param_type=9,
+                                  param_count=2, param_index=0xFFFF)
+        table.update(unknown)
+        self.assertEqual(table.received, {0})
+        self.assertFalse(table.complete())
+
+    def test_param_table_setitem_accepts_values_and_entries(self):
+        table = ParamTable()
+        table["X"] = 5.0
+        table["Y"] = ParamEntry(3.0, VKFly.MAV_PARAM_TYPE_UINT8, 1)
+        self.assertEqual(table.get("X"), 5.0)
+        self.assertEqual(table.get("Y"), 3.0)
+        self.assertEqual(table.entries["Y"].param_type, VKFly.MAV_PARAM_TYPE_UINT8)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 3 - 1
tests/test_bridge.py

@@ -156,7 +156,9 @@ class ValidationTests(unittest.TestCase):
 
     def test_config_validation(self):
         for changes in ({"system_id": "x/#"}, {"uav_type": -1},
-                        {"max_chunk_bytes": 0}, {"mqtt_password_env": "PASS"}):
+                        {"max_chunk_bytes": 0}, {"mqtt_password_env": "PASS"},
+                        {"request_params_on_start": 1}, {"param_request_timeout": 0},
+                        {"param_request_max_attempts": 0}):
             with self.subTest(changes=changes), self.assertRaises(ValueError):
                 config(**changes)
 

+ 85 - 0
tests/test_commands.py

@@ -3,6 +3,7 @@ import unittest
 
 from motian_bridge import Bridge
 from motian_bridge.dialects.v20 import VKFly
+from motian_bridge.telemetry import Telemetry
 from test_bridge import FakeMqtt, FakeSerial, command, config
 
 
@@ -137,6 +138,29 @@ class CommandHandlerTests(unittest.TestCase):
         land = self.last()
         self.assertEqual(land.command, VKFly.MAV_CMD_NAV_LAND)
 
+    def test_emergency_stop_with_parachute(self):
+        self.deliver(2073, None)
+        message = self.last()
+        self.assertEqual(message.get_type(), "COMMAND_LONG")
+        self.assertEqual(message.command, VKFly.MAV_CMD_DO_PARACHUTE)
+        self.assertEqual(message.param1, VKFly.PARACHUTE_RELEASE)
+        self.assertEqual((message.target_system, message.target_component), (42, 1))
+        before = len(self.serial.written)
+        self.deliver(2073, {})   # Data must be null
+        self.assertEqual(len(self.serial.written), before)
+
+    def test_emergency_stop_motors_ground(self):
+        self.deliver(2074, None)
+        message = self.last()
+        self.assertEqual(message.get_type(), "COMMAND_LONG")
+        self.assertEqual(message.command, VKFly.MAV_CMD_COMPONENT_ARM_DISARM)
+        self.assertEqual(message.param1, 0)          # disarm
+        self.assertEqual(message.param2, 21196)      # force
+        self.assertEqual((message.target_system, message.target_component), (42, 1))
+        before = len(self.serial.written)
+        self.deliver(2074, {})   # Data must be null
+        self.assertEqual(len(self.serial.written), before)
+
     def test_reposition_command_int(self):
         self.deliver(2070, {
             "latitude": 33.3180863,
@@ -167,5 +191,66 @@ class CommandHandlerTests(unittest.TestCase):
         self.assertEqual(len(self.serial.written), before)
 
 
+class ParameterSyncTests(unittest.TestCase):
+    def setUp(self):
+        self.cfg = config(param_request_timeout=1.0, param_request_max_attempts=2)
+        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)
+        self.bridge._telemetry.feed(heartbeat(42, 1), now=0)
+
+    def request_lists(self):
+        messages = [decode(frame) for frame in self.serial.written]
+        return [message for message in messages if message.get_type() == "PARAM_REQUEST_LIST"]
+
+    def test_auto_request_once_targets_locked_controller(self):
+        self.bridge._sync_parameters(now=0)
+        frames = self.request_lists()
+        self.assertEqual(len(frames), 1)
+        self.assertEqual((frames[0].target_system, frames[0].target_component), (42, 1))
+        self.bridge._sync_parameters(now=0.1)
+        self.assertEqual(len(self.serial.written), 1)
+
+    def test_disabled_config_never_requests(self):
+        bridge = Bridge(config(request_params_on_start=False), self.mqtt,
+                        lambda *args, **kwargs: self.serial)
+        bridge._serial = self.serial
+        bridge._telemetry.feed(heartbeat(42, 1), now=0)
+        bridge._sync_parameters(now=0)
+        self.assertEqual(self.serial.written, [])
+
+    def test_retry_until_max_attempts_then_give_up(self):
+        self.bridge._sync_parameters(now=0)
+        mav = VKFly.MAVLink(None, srcSystem=42, srcComponent=1)
+        self.bridge._telemetry.feed(mav.param_value_encode(
+            b"P", 1.0, VKFly.MAV_PARAM_TYPE_REAL32, 3, 0).pack(mav), now=0.1)
+        self.bridge._sync_parameters(now=0.5)   # timeout not reached yet
+        self.assertEqual(len(self.serial.written), 1)
+        self.bridge._sync_parameters(now=1.0)   # second attempt
+        self.assertEqual(len(self.serial.written), 2)
+        self.bridge._sync_parameters(now=2.0)   # attempts exhausted
+        self.assertEqual(len(self.serial.written), 2)
+
+    def test_complete_table_stops_retry(self):
+        self.bridge._sync_parameters(now=0)
+        mav = VKFly.MAVLink(None, srcSystem=42, srcComponent=1)
+        self.bridge._telemetry.feed(mav.param_value_encode(
+            b"P", 1.0, VKFly.MAV_PARAM_TYPE_REAL32, 1, 0).pack(mav), now=0.1)
+        self.assertTrue(self.bridge._telemetry.aircraft.params.complete())
+        self.bridge._sync_parameters(now=99.0)
+        self.assertEqual(len(self.serial.written), 1)
+
+    def test_new_aircraft_requests_again(self):
+        self.bridge._sync_parameters(now=0)
+        self.assertEqual(len(self.serial.written), 1)
+        self.bridge._telemetry = Telemetry(self.cfg)
+        self.bridge._telemetry.feed(heartbeat(7, 1), now=0)
+        self.bridge._sync_parameters(now=0)
+        self.assertEqual(len(self.serial.written), 2)
+        self.assertEqual(decode(self.serial.written[-1]).target_system, 7)
+
+
 if __name__ == "__main__":
     unittest.main()

+ 34 - 0
tests/test_telemetry.py

@@ -38,6 +38,16 @@ def position(mav):
         520, 110, -30, 8730).pack(mav)
 
 
+def decode_frame(frame):
+    parser = VKFly.MAVLink(None)
+    result = None
+    for byte in frame:
+        message = parser.parse_char(bytes((byte,)))
+        if message is not None:
+            result = message
+    return result
+
+
 class ParserTests(unittest.TestCase):
     def test_split_frames_crc_and_v1_heartbeat(self):
         telemetry = Telemetry(cfg())
@@ -254,5 +264,29 @@ class BridgeTelemetryTests(unittest.TestCase):
             bridge.stop()
 
 
+    def test_serial_loop_auto_downloads_parameters(self):
+        from test_bridge import FakeMqtt, FakeSerial
+
+        mav = sender()
+        fake_serial = FakeSerial()
+        fake_serial.to_read.append(heartbeat(mav, True))
+        conf = cfg(publish_raw_mavlink=False)
+        mqtt = FakeMqtt()
+        bridge = Bridge(conf, mqtt, lambda *args, **kwargs: fake_serial)
+        bridge._on_connect(mqtt, None, None, 0, None)
+        bridge.start()
+        try:
+            deadline = time.monotonic() + 2
+            while not fake_serial.written and time.monotonic() < deadline:
+                time.sleep(0.01)
+            types = [decode_frame(frame).get_type() for frame in fake_serial.written]
+            self.assertIn("PARAM_REQUEST_LIST", types)
+            request = next(decode_frame(frame) for frame in fake_serial.written
+                           if decode_frame(frame).get_type() == "PARAM_REQUEST_LIST")
+            self.assertEqual((request.target_system, request.target_component), (1, 1))
+        finally:
+            bridge.stop()
+
+
 if __name__ == "__main__":
     unittest.main()