Files
code-test-drone/utils/task_runner/runner.py
T
keita df91b1110e 精简仓库结构并增强任务链路:归档双目可选后端、新增LOS判据与自定义返航
归档 legacy/:stereo_yolo/stereo_ranger/target_tracker 三包 + 175 条测试 + demo/配置/权重/三份规格;运行链路不再依赖,默认 pytest 446 条,归档测试按需运行。

目标有效性第二判据改为 LOS 运动学一致性(pod_bridge/los_consistency.py),删除 require_vision;pod_locked() 叠加该判据,状态机随之后退重搜。

自定义返航(task_runner/return_home.py):保持转场高度回起飞点 → 恢复初始航向 → 垂直降落,不再爬 RTL_RETURN_ALT;未起飞/无起飞点/超时/异常仍退回 PX4 原生 RTL。

任务链安全兜底:主循环异常→紧急返航、stop 幂等、landing_stop 保留遥测、in_air 流复用+超时、解锁/起飞与吊舱识别仅执行一次、rtl_timeout 上限。

入口共享件拆到 utils/sitl_support.py;pod_sitl_test.py→l1_sitl_test.py;删除旧架构 test.py;相机脚本移入 tools/。

新增 .gitignore 并剔除历史误跟踪的 59 个 .pyc;requirements 按实际依赖重写;文档全面对齐,新增 legacy/README.md 与 tools/README.md。
2026-10-21 14:48:41 +08:00

682 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""runner.py —— 任务编排器:驱动任务状态机跑通 WAIT→…→RTL 全流程。
「预定流程.md」天空侧的落地:把已通的 L1(航向伺服 TrackingControlLaw)与
L2(地理引导 GuidanceLaw)按任务阶段编排,配合地面坐标注入(GroundLink)与
状态机(TaskStateMachine),串成一条可运行的全流程。
职责边界:
* 状态机(TaskStateMachine)只做「状态 + 转移判据」决策;
* 本编排器负责「采集快照 → 喂状态机 → 按状态执行动作」;
* 地面站命令(RTL/紧急/吊舱手动指令,经 GroundLink 队列)主循环逐拍分派;
* 飞控状态文本(STATUSTEXT,警告/异常)逐拍落任务日志(fc_status 事件);
* 闭环控制复用 PodBridge(L1/L2 运行时切换走 set_heading_servo_enabled);
* 飞行编排(解锁/起飞/返航)通过 duck-typed `drone`(MAVSDK System)完成,
便于 mock 单测;真实 SITL 装配见仓库根 task_runner_sitl.py。
生命周期:run() 开头先订阅 PX4 遥测(幂等),用于起飞到位 / 距离计算;
GOTO 进入时 start offboard;RTL 进入时安全停机 + 返航。
"""
import asyncio
import logging
import math
import time
from typing import Callable, Optional
from pod_bridge.guidance import haversine_distance_m
from visual_track_control.staleness_checker import is_stale
from visual_track_control.types import ControlMode, ControlOutput
from .log import TaskLogger, TelemetryRecord
from .return_home import HomingController, ReturnConfig
from .state_machine import (TaskInputs, TaskState, TaskStateConfig,
TaskStateMachine)
logger = logging.getLogger(__name__)
# 自定义返航的阶段
_RTL_RETURN = "return" # 转场:飞回起飞点,保持转场高度
_RTL_YAW = "yaw" # 到位:把机头转回初始航向
_RTL_LAND = "land" # 降落:交给飞控垂直降落
_RTL_FALLBACK = "fallback" # 兜底:退回 PX4 原生 RTL(会爬升,仅异常时使用)
_RTL_IDLE = "idle"
# 地面站吊舱命令 → PodController 方法(与 ground_station/app.py 白名单对齐)
_POD_COMMAND_METHODS = {
"pod_detect_on": "arm_detection",
"pod_auto_lock": "auto_lock",
"pod_stop_track": "stop_track",
"pod_detect_off": "stop_tracking",
"pod_center": "center",
}
# 搜索扫视换向周期计数中「向右」的半周期序号(0 → 右,1 → 左,交替)
_SWEEP_RIGHT, _SWEEP_LEFT = 0, 1
# RTL 落地判定:单次等待 in_air 遥测的上限(秒)。MAVSDK 遥测流挂起时
# `async for` 会永久阻塞并卡死主循环,故必须有兜底(超时 → 下一拍重试)。
_IN_AIR_TIMEOUT_S = 5.0
def _monotonic_ms() -> int:
return int(time.monotonic() * 1000)
class TaskRunner:
"""任务编排器。依赖(除 sm 外均可注入 mock):
cfg BridgeConfig —— ground_inject.timeout_ms 等
sm_cfg TaskStateConfig —— 切换半径/超时/起飞高度/居中阈值/循环频率
px4 Px4Gateway —— own_state()(姿态/位置/相对高度)
bridge PodBridge —— step / set_heading_servo_enabled / pod_locked
sm TaskStateMachine —— update()
ground GroundLink —— latest()(地面注入坐标)
drone MAVSDK System —— action.arm/takeoff/return_to_launch/land/disarm
"""
def __init__(self, cfg, sm_cfg: TaskStateConfig, px4, bridge, sm: TaskStateMachine,
ground, drone,
now_ms_fn: Callable[[], int] = _monotonic_ms,
logger: Optional[TaskLogger] = None,
pod_control=None, land_instead_of_rtl: bool = False,
return_cfg: Optional[ReturnConfig] = None) -> None:
self._cfg = cfg
self._sm_cfg = sm_cfg
self._px4 = px4
self._bridge = bridge
self._sm = sm
self._ground = ground
self._drone = drone
self._now = now_ms_fn
self._logger = logger # 结构化任务日志(可选,B 阶段)
self._pod_control = pod_control # 吊舱识别/锁定控制(可选,真机)
self._land_instead_of_rtl = land_instead_of_rtl # 收尾改用就地降落
self._return_cfg = return_cfg or ReturnConfig()
self._centered_since_ms: Optional[int] = None
self._rtl_requested = False
self._emergency = False
self._rtl_done = False
# SEARCH 扫视状态:当前扫向(右/左)与本次扫向的起始时刻
self._sweep_dir = _SWEEP_RIGHT
self._sweep_since_ms: Optional[int] = None
self._pod_detection_on = False # 吊舱识别/自动锁定只开一次(防重发 0x07)
self._takeoff_done = False # 解锁/起飞只执行一次(防空中重复起飞)
self._in_air_stream = None # in_air 遥测流复用(避免每拍重新订阅)
self._rtl_entered_ms: Optional[int] = None # 进入 RTL 时刻(落地等待上限用)
# --- 自定义返航(回起飞点 → 恢复初始航向 → 降落)---
self._home_lat: Optional[float] = None # 起飞点纬度(解锁时刻记录)
self._home_lon: Optional[float] = None
self._initial_yaw_rad: Optional[float] = None # 起飞时刻航向
self._bridge_started = False # 是否已进入 offboard(未起飞则不能自主返航)
self._rtl_phase = _RTL_IDLE
self._return_ctrl: Optional[HomingController] = None
self._return_started_ms: Optional[int] = None
self._yaw_started_ms: Optional[int] = None
self._land_cmd_sent = False
# --- 外部控制 -----------------------------------------------------------
def request_rtl(self) -> None:
"""外部请求返航(如收到地面站返航命令 / 人工按键)。"""
self._rtl_requested = True
def trigger_emergency(self) -> None:
"""外部触发紧急终止(丢控 / RC 接管 / 失控)。"""
self._emergency = True
# --- 地面站命令分派 -----------------------------------------------------
def _apply_ground_commands(self) -> None:
"""排空地面站命令队列并逐条分派(主循环每拍调用)。"""
for name in self._ground.drain_commands():
self._apply_command(name)
def _apply_command(self, name: str) -> None:
"""分派一条地面站命令;未知命令名忽略并警告。"""
if name == "rtl":
self.request_rtl()
elif name == "emergency":
self.trigger_emergency()
elif name in _POD_COMMAND_METHODS:
if self._pod_control is None:
logger.warning("[Task] 地面站命令 %s 被忽略:未配置吊舱控制(SITL)",
name)
return
try:
getattr(self._pod_control, _POD_COMMAND_METHODS[name])()
except Exception as exc:
logger.warning("[Task] 吊舱命令 %s 执行失败: %s", name, exc)
return
else:
logger.warning("[Task] 未知地面站命令: %s", name)
return
logger.info("[Task] 地面站命令: %s", name)
if self._logger is not None:
self._logger.log_event(self._now(), "command", name=name)
def _log_fc_messages(self) -> None:
"""把飞控状态文本(STATUSTEXT)落任务日志并输出控制台(每拍调用)。"""
for sev, text in self._px4.take_fc_messages():
logger.info("[FC] %s: %s", sev, text)
if self._logger is not None:
self._logger.log_event(self._now(), "fc_status",
severity=sev, text=text)
@property
def state(self) -> TaskState:
return self._sm.state
@property
def rtl_done(self) -> bool:
return self._rtl_done
# --- 主循环 -------------------------------------------------------------
async def run(self, stop_event: asyncio.Event) -> None:
"""主循环:采集快照 → 状态机 → 进入动作/持续动作,直到 RTL 完成或停止。"""
await self._px4.start_telemetry() # 幂等:先拿遥测,供起飞到位/距离计算
if self._logger is not None:
self._logger.open()
# 记初始状态:页面「任务状态」取日志最后一个 state 事件,缺此行会显示上次旧状态
self._logger.log_event(self._now(), "state",
from_state=self._sm.state.value,
to_state=self._sm.state.value,
reason="任务启动")
period_s = 1.0 / self._sm_cfg.loop_frequency_hz
try:
while not stop_event.is_set():
now_ms = self._now()
try:
await self._iteration(now_ms)
except asyncio.CancelledError:
raise
except Exception as exc:
# 单周期异常不得让任务带着 offboard 指令静默停摆:转紧急 → 下一拍
# 状态机转 RTL(与 bridge.run() 的按周期护栏同思路,但这里必须
# 升级为返航,因为再没有别的循环会接管飞机)。
logger.error("[Task] 任务周期异常,转紧急返航: %s", exc)
self._emergency = True
if self._logger is not None:
self._logger.log_event(self._now(), "iteration_error",
error=f"{type(exc).__name__}: {exc}")
await self._safe_abort()
if self._finished():
break
await asyncio.sleep(period_s)
finally:
await self._teardown()
if self._logger is not None:
self._logger.close()
async def _iteration(self, now_ms: int) -> None:
"""单周期:命令 → 快照 → 状态机 → 进入动作 → 持续动作 → 日志。"""
self._apply_ground_commands() # 地面站命令先于快照采集生效
self._log_fc_messages()
inputs = self._collect_inputs(now_ms)
tr = self._sm.update(inputs, now_ms)
if tr.occurred:
logger.info("[Task] %s -> %s (%s)",
tr.from_state.value, tr.to_state.value, tr.reason)
if self._logger is not None:
self._logger.log_event(
now_ms, "state", from_state=tr.from_state.value,
to_state=tr.to_state.value, reason=tr.reason)
await self._on_enter(tr.to_state, now_ms)
out = await self._tick(now_ms)
if self._logger is not None:
self._logger.log_telemetry(
now_ms, self._build_telemetry_record(now_ms, inputs, out))
async def _safe_abort(self) -> None:
"""异常兜底:直接进入 RTL 流程(不依赖状态机下一拍转移)。
异常可能来自采集/控制律/日志任意环节,下一拍未必能正常跑完转移;因此在
兜底里显式走一次 RTL 进入动作(停机 + 返航),保证飞机不会停在 offboard
速度指令上。
"""
if self._sm.state is TaskState.RTL:
return
self._sm.force_state(TaskState.RTL, self._now())
self._rtl_entered_ms = self._now()
if self._pod_control is not None:
try:
self._pod_control.stop_tracking()
except Exception:
pass
try:
# 兜底不走自定义返航:异常后不再信任自身状态/定位,直接交给 PX4 原生 RTL
await self._begin_rtl(self._now(), force_fallback=True)
except Exception as exc:
logger.error("[Task] 异常兜底返航失败: %s", exc)
# --- 输入采集 -----------------------------------------------------------
def _collect_inputs(self, now_ms: int) -> TaskInputs:
"""聚合 PX4 / 注入坐标 / 吊舱 / 视觉为一份 TaskInputs 快照。"""
own = self._px4.own_state()
self._maybe_record_home(own) # 起飞阶段定位可能晚到:每拍补记一次
injected = self._ground.latest()
target_injected = bool(
injected.time_ms > 0
and not is_stale(injected.time_ms, now_ms,
self._cfg.ground_inject.timeout_ms))
distance = math.inf
if target_injected and own.position_valid:
distance = haversine_distance_m(
own.latitude_deg, own.longitude_deg,
injected.latitude_deg, injected.longitude_deg)
# 起飞到达判据带容差:悬停/高度估计通常略低于设定目标(实测 SITL 4.99m vs 5.0m),
# 无容差会差厘米误判"起飞超时"→ 真机上更明显
takeoff_reached = (own.relative_altitude_m
>= self._sm_cfg.takeoff_altitude_m
- self._sm_cfg.takeoff_reached_tolerance_m)
target_locked = self._bridge.pod_locked(now_ms)
centered, centered_s = self._update_centered(now_ms)
return TaskInputs(
target_injected=target_injected,
distance_to_target_m=distance,
takeoff_reached=takeoff_reached,
target_locked=target_locked,
target_centered=centered,
centered_duration_s=centered_s,
rtl_requested=self._rtl_requested,
emergency=self._emergency,
)
def _update_centered(self, now_ms: int) -> tuple[bool, float]:
"""脱靶量居中判定 + 持续时长累加(TRACK→APPROACH 判据)。
无遥测 / 任一轴脱靶量超出 center_threshold_deg → 清零累加。
"""
tel = self._bridge.latest_telemetry
if tel is None:
self._centered_since_ms = None
return False, 0.0
frame, _ = tel
centered = (abs(frame.off_target_x_deg) < self._sm_cfg.center_threshold_deg
and abs(frame.off_target_y_deg) < self._sm_cfg.center_threshold_deg)
if not centered:
self._centered_since_ms = None
return False, 0.0
if self._centered_since_ms is None:
self._centered_since_ms = now_ms
return True, (now_ms - self._centered_since_ms) / 1000.0
# --- 状态进入动作 -------------------------------------------------------
async def _on_enter(self, state: TaskState, now_ms: int) -> None:
"""进入状态的**一次性**动作。
注意:这些动作只在「首次」进入时执行——任务中途可能需要多次经过 GOTO/
SEARCH(丢目标回搜),重复 `offboard.start()` / `arm_detection()`
(0x07 目标识别开会打断正在进行的跟踪)会造成真机自激,故以标志位守卫。
"""
if state is TaskState.ARM_TAKEOFF:
await self._arm_takeoff()
elif state is TaskState.GOTO:
if not await self._bridge.start():
logger.error("[Task] offboard 启动失败,紧急终止")
self._emergency = True
return
self._bridge_started = True
self._bridge.set_heading_servo_enabled(False) # L2 地理引导
self._start_pod_detection() # 首次:开识别 + 自动锁定
elif state is TaskState.SEARCH:
self._bridge.set_heading_servo_enabled(False) # 搜索不用控制律
self._sweep_dir = _SWEEP_RIGHT # 每次进入从右向起扫
self._sweep_since_ms = now_ms
elif state is TaskState.TRACK:
self._bridge.set_heading_servo_enabled(True) # L1 视觉追踪 + 缓慢靠近
elif state is TaskState.APPROACH:
pass # 悬停由 _tick 持续发零速
elif state is TaskState.RTL:
self._rtl_entered_ms = now_ms
if self._pod_control is not None:
self._pod_control.stop_tracking() # 真机:停止跟踪 + 识别关
await self._begin_rtl(now_ms)
def _start_pod_detection(self) -> None:
"""首次到达引导/搜索阶段时开启吊舱目标识别 + 自动锁定(只发一次)。"""
if self._pod_control is None or self._pod_detection_on:
return
self._pod_detection_on = True
self._pod_control.arm_detection()
# --- SEARCH 扫视 --------------------------------------------------------
def _search_yaw_rate(self, now_ms: int) -> float:
"""SEARCH 扫视角速度:按 sweep_reverse_s 交替左右,0 = 单向扫视。
吊舱相机刚性固连机体(无电机、不独立转动),故只能靠机头旋转改变观察
方向;扫视期间零线速度,飞机保持悬停在参考点附近。
"""
rate = self._sm_cfg.sweep_rate_dps
if rate == 0.0:
return 0.0
period_s = self._sm_cfg.sweep_reverse_s
if period_s > 0.0:
if self._sweep_since_ms is None:
self._sweep_since_ms = now_ms
elif (now_ms - self._sweep_since_ms) >= period_s * 1000.0:
self._sweep_since_ms = now_ms
self._sweep_dir = (_SWEEP_LEFT if self._sweep_dir == _SWEEP_RIGHT
else _SWEEP_RIGHT)
sign = 1.0 if self._sweep_dir == _SWEEP_RIGHT else -1.0
return sign * rate
# --- 每周期持续动作 -----------------------------------------------------
async def _tick(self, now_ms: int):
"""每周期持续动作。返回本拍控制输出(GOTO/TRACK 有,其余 None)。"""
state = self._sm.state
if state in (TaskState.GOTO, TaskState.TRACK):
return await self._bridge.step(now_ms)
elif state is TaskState.SEARCH:
# 原地偏航扫视(同时 poll 吊舱,保遥测新鲜,否则搜到也切不进 TRACK)
await self._bridge.search_step(now_ms, self._search_yaw_rate(now_ms))
elif state is TaskState.APPROACH:
await self._px4.send_brake_hover(now_ms) # 悬停目标上空(保 offboard 心跳)
elif state is TaskState.RTL:
await self._tick_rtl(now_ms)
return None
# WAIT / ARM_TAKEOFF:无持续动作(待命 / 等起飞高度)
def _build_telemetry_record(self, now_ms: int, inputs: TaskInputs,
out) -> TelemetryRecord:
"""聚合本拍遥测为一帧 TelemetryRecord(供日志 / 页面复用)。"""
own = self._px4.own_state()
tel = self._bridge.latest_telemetry
off_x = off_y = 0.0
if tel is not None:
frame, _ = tel
off_x = frame.off_target_x_deg
off_y = frame.off_target_y_deg
return TelemetryRecord(
state=self._sm.state.value,
pos_valid=own.position_valid,
fix_type=own.fix_type,
num_satellites=own.num_satellites,
latitude_deg=own.latitude_deg,
longitude_deg=own.longitude_deg,
relative_altitude_m=own.relative_altitude_m,
yaw_deg=math.degrees(own.yaw_rad),
target_injected=inputs.target_injected,
distance_to_target_m=inputs.distance_to_target_m,
target_locked=inputs.target_locked,
off_target_x_deg=off_x,
off_target_y_deg=off_y,
mode=out.mode.name if out is not None else "",
yaw_rate_dps=out.yaw_rate_dps if out is not None else 0.0,
vx_body_mps=out.vx_body_mps if out is not None else 0.0,
vz_mps=out.vz_mps if out is not None else 0.0,
)
# --- 飞行编排 -----------------------------------------------------------
async def _arm_takeoff(self) -> None:
"""解锁 + 起飞到悬停高度(每次任务只执行一次)。
重复执行会导致:机已在空中时再次 arm/takeoff → PX4 拒绝甚至触发保护,
且日志看起来像"重新起飞"。故用标志位守卫,重复进入只告警不改动飞机。
"""
if self._takeoff_done:
logger.warning("[Task] 已在飞行中,忽略重复的解锁/起飞请求")
return
self._takeoff_done = True
# 解锁前记录起飞点与初始航向(自定义返航要用;PX4 的 home 此时已固定)
self._record_home()
try:
await self._drone.action.arm()
await self._drone.action.set_takeoff_altitude(
self._sm_cfg.takeoff_altitude_m)
await self._drone.action.takeoff()
if self._logger is not None:
self._logger.log_event(
self._now(), "takeoff",
altitude_m=self._sm_cfg.takeoff_altitude_m)
except Exception as exc:
logger.error("[Task] 起飞失败: %s", exc)
self._emergency = True
if self._logger is not None:
self._logger.log_event(self._now(), "takeoff_failed",
error=str(exc))
def _record_home(self) -> bool:
"""尝试记录起飞点与初始航向;成功返回 True(可重复调用,只记一次)。"""
if self._home_lat is not None:
return True
try:
own = self._px4.own_state()
except Exception as exc: # 遥测不可用时不阻塞起飞
logger.warning("[Task] 读取起飞点失败: %s", exc)
return False
return self._maybe_record_home(own)
def _maybe_record_home(self, own) -> bool:
"""在**起飞前/起飞中**用首个有效定位记下起飞点与初始航向。
⚠ SITL 实测踩到的坑:解锁发生在地面站坐标到达后约 0.3s,此时 `gps_info`
(2Hz)可能还没到,`position_valid` 仍为 False——若只在解锁那一刻记一次,
起飞点就会丢失、自定义返航只能退回 PX4 RTL。故起飞阶段每拍补记。
只接受 WAIT / ARM_TAKEOFF 阶段的定位:进入 GOTO 后飞机已在空中,
那时的位置不该再当作"起飞点"。
"""
if self._home_lat is not None:
return True
if not own.position_valid:
return False
if self._sm.state not in (TaskState.WAIT, TaskState.ARM_TAKEOFF):
return False
self._home_lat = own.latitude_deg
self._home_lon = own.longitude_deg
self._initial_yaw_rad = own.yaw_rad
logger.info("[Task] 起飞点已记录: (%.7f, %.7f) 初始航向 %.1f°",
self._home_lat, self._home_lon,
math.degrees(self._initial_yaw_rad))
return True
# --- 返航(自定义:回起飞点 → 恢复初始航向 → 降落)---------------------
def _custom_return_possible(self) -> bool:
"""能否走自定义返航(已起飞 + 有起飞点 + 位置有效)。"""
if not self._return_cfg.enabled:
return False
if not self._bridge_started:
return False # 没进过 offboard:还在待命/起飞段
if self._home_lat is None or self._home_lon is None:
return False
if self._initial_yaw_rad is None:
return False
try:
return bool(self._px4.own_state().position_valid)
except Exception:
return False
async def _begin_rtl(self, now_ms: int, force_fallback: bool = False) -> None:
"""进入返航:选择「自定义返航」或「就地降落」或「PX4 RTL 兜底」。"""
if self._land_instead_of_rtl:
self._rtl_phase = _RTL_LAND
await self._start_land()
return
if force_fallback or not self._custom_return_possible():
reason = "异常兜底" if force_fallback else "未起飞/无起飞点"
logger.info("[Task] 使用 PX4 原生 RTL(%s)", reason)
await self._fallback_rtl()
return
own = self._px4.own_state()
# 转场高度:取"当前高度"与"起飞高度"的较大者 —— 不低于任务高度,
# 也不会为了返航额外爬高(这正是原生 RTL 会做的事)。
transit_alt = max(own.relative_altitude_m,
self._sm_cfg.takeoff_altitude_m)
self._return_ctrl = HomingController(
self._return_cfg, self._home_lat, self._home_lon,
self._initial_yaw_rad, transit_alt)
self._return_started_ms = now_ms
self._rtl_phase = _RTL_RETURN
logger.info("[Task] 自定义返航:回起飞点 (%.7f, %.7f),转场高度 %.1fm,"
"到位后恢复航向 %.1f° 并降落",
self._home_lat, self._home_lon, transit_alt,
math.degrees(self._initial_yaw_rad))
if self._logger is not None:
self._logger.log_event(
now_ms, "return_home", home_lat=self._home_lat,
home_lon=self._home_lon, transit_alt_m=transit_alt,
initial_yaw_deg=math.degrees(self._initial_yaw_rad))
async def _return_step(self, now_ms: int) -> None:
"""转场阶段:朝起飞点飞,保持转场高度。"""
cfg = self._return_cfg
if (self._return_started_ms is not None
and now_ms - self._return_started_ms >= cfg.timeout_s * 1000.0):
logger.warning("[Task] 返航转场超时 %.0fs,退回 PX4 RTL", cfg.timeout_s)
if self._logger is not None:
self._logger.log_event(now_ms, "return_timeout",
timeout_s=cfg.timeout_s)
await self._fallback_rtl()
return
own = self._px4.own_state()
if not own.position_valid:
# 定位丢失:不能自主返航,先刹停等恢复(不盲目飞)
logger.warning("[Task] 返航中定位失效,刹停等待")
await self._px4.send_brake_hover(now_ms)
return
cmd = self._return_ctrl.update(own.latitude_deg, own.longitude_deg,
own.yaw_rad, own.relative_altitude_m)
out = ControlOutput(mode=ControlMode.VELOCITY_HEADING, valid=True,
vx_body_mps=cmd.vx_mps, vz_mps=cmd.vz_mps,
yaw_rate_dps=cmd.yaw_rate_dps)
await self._px4.send_velocity_heading(out, now_ms)
if cmd.arrived:
logger.info("[Task] 已回到起飞点(距 %.1fm),开始恢复初始航向",
cmd.distance_m)
self._rtl_phase = _RTL_YAW
self._yaw_started_ms = now_ms
async def _yaw_align_step(self, now_ms: int) -> None:
"""恢复初始航向阶段:原地转回起飞时刻的航向。"""
cfg = self._return_cfg
own = self._px4.own_state()
err, rate = self._return_ctrl.yaw_align(own.yaw_rad)
# 保持转场高度(不额外爬升)
out = ControlOutput(mode=ControlMode.VELOCITY_HEADING, valid=True,
vx_body_mps=0.0,
vz_mps=self._return_ctrl.alt_hold_vz(
own.relative_altitude_m),
yaw_rate_dps=rate)
await self._px4.send_velocity_heading(out, now_ms)
timed_out = (self._yaw_started_ms is not None
and now_ms - self._yaw_started_ms
>= cfg.yaw_align_timeout_s * 1000.0)
if abs(err) <= cfg.yaw_align_tolerance_deg:
logger.info("[Task] 航向已恢复(误差 %.1f°),开始降落", err)
elif timed_out:
logger.warning("[Task] 航向对齐超时(误差 %.1f°),仍进入降落", err)
else:
return
if self._logger is not None:
self._logger.log_event(now_ms, "yaw_restored",
err_deg=err, timed_out=timed_out)
self._rtl_phase = _RTL_LAND
await self._start_land()
async def _start_land(self) -> None:
"""退出 offboard 并垂直降落(不爬升)。"""
if self._land_cmd_sent:
return
self._land_cmd_sent = True
# 只退 offboard、保留遥测:落地判定与高度曲线都依赖遥测(SITL 实测过:
# 若在这里就全停,日志会冻结在降落前的高度,看不出下降)。
stop_for_landing = getattr(self._bridge, "landing_stop", None)
try:
if stop_for_landing is not None:
await stop_for_landing()
else:
await self._bridge.stop()
except Exception as exc:
logger.warning("[Task] 停机异常: %s", exc)
try:
await self._drone.action.land()
except Exception as exc:
logger.warning("[Task] 降落指令失败,尝试 PX4 RTL 兜底: %s", exc)
try:
await self._drone.action.return_to_launch()
except Exception:
pass
if self._logger is not None:
self._logger.log_event(self._now(), "rtl", action="land")
async def _fallback_rtl(self) -> None:
"""兜底:停机后交给 PX4 原生 RTL(会先爬升到 RTL_RETURN_ALT)。"""
self._rtl_phase = _RTL_FALLBACK
try:
await self._bridge.stop()
except Exception as exc:
logger.warning("[Task] 停机异常: %s", exc)
try:
await self._drone.action.return_to_launch()
except Exception as exc:
logger.warning("[Task] RTL 失败,尝试降落: %s", exc)
try:
await self._drone.action.land()
except Exception:
pass
if self._logger is not None:
self._logger.log_event(self._now(), "rtl", action="rtl")
async def _tick_rtl(self, now_ms: int) -> None:
"""RTL 状态机:按阶段推进(转场 → 恢复航向 → 降落 / 兜底)。"""
phase = self._rtl_phase
if phase == _RTL_RETURN:
await self._return_step(now_ms)
elif phase == _RTL_YAW:
await self._yaw_align_step(now_ms)
elif phase == _RTL_LAND:
await self._check_landed()
elif phase == _RTL_FALLBACK:
await self._check_landed()
# 整体落地等待上限:任何阶段都不得无限等
if (not self._rtl_done and self._rtl_entered_ms is not None
and now_ms - self._rtl_entered_ms
>= self._sm_cfg.rtl_timeout_s * 1000.0):
logger.warning("[Task] RTL 等待落地超过 %.0fs,结束任务(无法确认落地)",
self._sm_cfg.rtl_timeout_s)
self._rtl_done = True
if self._logger is not None:
self._logger.log_event(now_ms, "rtl_timeout",
timeout_s=self._sm_cfg.rtl_timeout_s)
async def _check_landed(self) -> None:
"""RTL 状态每周期取一帧 in_air 遥测;落地后 disarm 并标记完成。
两个要点:
* `in_air()` 是 MAVSDK 遥测流:流挂起时 `async for` 会**永久阻塞**,
主循环随之卡死(rtl_done 永不置位)。故加超时兜底,超时下拍重试。
* 生成器**复用**(`_in_air_stream`):每拍新建一个 `in_air()` 会在
mavsdk_server 侧反复订阅/退订同一条流(20Hz × 整个 RTL 段),是
回调队列的无谓负担;复用后只订阅一次。
"""
try:
if self._in_air_stream is None:
self._in_air_stream = self._drone.telemetry.in_air()
async with asyncio.timeout(_IN_AIR_TIMEOUT_S):
async for is_in_air in self._in_air_stream:
if not is_in_air and not self._rtl_done:
self._rtl_done = True
try:
await self._drone.action.disarm()
except Exception:
pass
break # 只取最新一帧
except (TimeoutError, asyncio.TimeoutError):
logger.debug("[Task] in_air 遥测超时 %.1fs,下拍重试", _IN_AIR_TIMEOUT_S)
except Exception:
# 建流或消费失败都算流不可用:丢弃缓存,下拍重建(不永久卡在坏流上)
self._in_air_stream = None
# --- 收尾 ---------------------------------------------------------------
def _finished(self) -> bool:
return self._sm.state is TaskState.RTL and self._rtl_done
async def _teardown(self) -> None:
"""退出主循环(含异常/中途停止)时的安全收尾。"""
try:
await self._bridge.stop()
except Exception:
pass