Files
opi-yolo/drone_controller/mission_manager.py
T
2026-07-01 09:03:24 +08:00

864 lines
34 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.
"""
mission_manager.py — 基于 ZeroMQ RouterProxy 的航点巡航状态机
【重构背景】
系统底层架构已升级为 ZMQ Router 代理通信协议。本模块彻底摒弃
旧版 DroneController 直连方式,全面迁移至 RouterProxy。
【核心改动】
1. self.drone → self.proxy(注入的 RouterProxy 实例)
2. 所有状态读取:proxy.get_latest_state()["drone"][...]
3. 所有指令发送:proxy.send_command() / send_waypoint() / send_setpoint()
4. 新增 STATE 新鲜度校验(协议 §3.8.1),过期数据保守处理
5. 新增 Failsafe 模式断流保护,退出 OFFBOARD 即熔断
6. VISUAL_TRACKING 状态下完全让渡控制权,不发送任何 SETPOINT
【位置估计】
Router 协议未暴露局部 NED 坐标,本模块使用速度积分
(vx × dt 累加)推算相对起飞点的水平位置,用于航点到
达判定。垂直轴直接使用 state["drone"]["alt_rel"]。
【控制权协调】
MissionManager 通过自有 proxy 发送指令。连续 20Hz SETPOINT
流由 UAVControlLoop 的独立线程负责。run_mission.py 负责协调:
在 VISUAL_TRACKING 外停止 UAVControlLoop 线程,避免两套
SETPOINT 流同时激活产生冲突。
"""
import time
import math
import logging
from enum import Enum
from sympy import true
logger = logging.getLogger(__name__)
class MissionState(Enum):
"""无人机任务状态枚举"""
WAITING_WAYPOINTS=-1 # 等待地面站下发航点
INIT = 0 # 初始化 / 等待连接
ARMING = 1 # 解锁中
TAKEOFF = 2 # 自动起飞中
NAVIGATING = 3 # 航点巡航导航
HOLD_TASK = 4 # 到达航点后的原地搜索 / 悬停
VISUAL_TRACKING = 5 # 🎯 视觉追踪中(控制权让渡给 UAVControlLoop)
RETURNING = 6 # 返航中
LANDING = 7 # 自动降落中
FINISHED = 8 # 任务结束 / 已上锁
HOLD_FINAL = 9 # 结束巡点后原地悬停(return_to_home=False)
class FailsafeTriggered(Exception):
"""
自定义安全异常:当安全员人工介入切出自控模式时抛出,
用于暴力熔断主循环。
"""
pass
# ================================================================
# 内部工具
# ================================================================
def _dist_3d(x1, y1, z1, x2, y2, z2):
"""三维欧氏距离"""
return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2)
# ================================================================
# 航点巡航管理器
# ================================================================
class MissionManager:
"""
🛸 ZMQ 航点巡航状态机管理器
职责:
- 封装完整的无人机任务生命周期(初始化 → 解锁 → 起飞 →
航点巡航 → 视觉追踪 → 返航 → 降落)
- 所有底层通信通过注入的 RouterProxy 完成
- 在 VISUAL_TRACKING 状态下让渡控制权给 UAVControlLoop
- Failsafe 熔断保护
使用示例:
proxy = RouterProxy()
proxy.start()
manager = MissionManager(proxy, waypoints=[...], target_altitude=1.5)
while True:
manager.update(target_detected=some_bool)
time.sleep(0.05)
"""
# ---- Failsafe:自主飞行的合法模式白名单 ----
AUTONOMOUS_MODES = frozenset({
"OFFBOARD", "GUIDED", "AUTO", "HOLD", "LAND", "RTL",
})
# ---- 位置估计:速度积分有效时间窗口 ----
MAX_POSITION_DT = 1.0
# ---- 目标丢失超时(秒) ----
DEFAULT_TARGET_LOST_TIMEOUT = 3.0
# ---- 各阶段超时 ----
TAKEOFF_TIMEOUT = 15.0
OFFBOARD_TIMEOUT = 5.0
ARM_TIMEOUT = 5.0
def __init__(self, proxy, waypoints, target_altitude=1.5,
arrival_radius=0.3, hold_duration=5.0,
return_to_home=True, target_lost_timeout=None):
"""
Args:
proxy: RouterProxy 实例(调用方负责 start/close)
waypoints: 航点列表 [{'x':1,'y':2,'z':-1.5,'yaw':0}, ...]
target_altitude: 默认起飞高度(米,正值=向上)
arrival_radius: 航点到达判定半径(米)
hold_duration: 巡点无目标的最大悬停搜索时间(秒)
return_to_home: 航点巡完后是否返航,False 则终点悬停
target_lost_timeout: VISUAL_TRACKING 目标丢失超时(秒)
"""
# ---- 依赖注入 ----
self.proxy = proxy
# ---- 任务参数 ----
# 航点列表(外部队列已经使用 NED 坐标系,Z 负值向上)
self.waypoints = list(waypoints) if waypoints else []
# 根据是否有航点决定初始状态
if self.waypoints:
# 有预置航点(如测试用硬编码),直接进入初始化
self.state = MissionState.INIT
self._waypoints_ready = True
logger.info("MissionManager 初始化完成,已有 %d 个航点", len(self.waypoints))
else:
# 无航点,进入等待状态(等待 QGC 下发)
self.state = MissionState.WAITING_WAYPOINTS
self._waypoints_ready = False
logger.info("MissionManager 初始化完成,等待 QGC 下发航点...")
self.target_altitude = -abs(target_altitude) # NED Z(负值向上)
self.arrival_radius = arrival_radius
self.hold_duration = hold_duration
self.return_to_home = return_to_home
self.target_lost_timeout = (
target_lost_timeout if target_lost_timeout is not None
else self.DEFAULT_TARGET_LOST_TIMEOUT
)
# ---- 状态机 ----
self.current_wp_index = 0
# ---- 当前目标航点(共享给外部主循环 / UAVControlLoop) ----
self.current_target = {
"x": 0.0, "y": 0.0,
"z": self.target_altitude, "yaw": 0.0,
}
# ================================================================
# 本地位置估计(速度积分法)
# ================================================================
self._est_x = 0.0 # NED 北向(相对起飞点)
self._est_y = 0.0 # NED 东向
self._pos_time = 0.0 # 上次位置更新时间戳
self._home_set = False # 首次 STATE 标记
# ---- 计时器 ----
self.hold_start_time = 0.0
self.tracking_start_time = 0.0
self._target_lost_start = None
self._state_start_time = time.time()
# ================================================================
# 追踪完成信号(外部设置)
# ================================================================
# UAVControlLoop 或 run_mission.py 在判定视觉追踪完成时置 True
self.tracking_completed = False
# 追踪最大时长兜底
self.tracking_max_duration = 10.0
logger.info("MissionManager 初始化: %d 个航点, 目标高度=%.1fm, "
"到达半径=%.1fm, 搜寻时长=%.1fs, 返航=%s",
len(self.waypoints), -self.target_altitude,
self.arrival_radius, self.hold_duration,
self.return_to_home)
# ---- 日志节流控制 ----
self._last_throttle_time = {} # 节流日志: key → 上次打印时间戳
self._last_throttle_state = {} # 状态缓存对比: key → 上次打印的值
# ================================================================
# 主入口
# ================================================================
def update(self, target_detected=False):
"""
状态机高频核心业务逻辑,由外部主循环驱动(建议 ≥20Hz)
Args:
target_detected: 当前帧视觉检测是否有目标
Raises:
FailsafeTriggered: 检测到非法模式切换时抛出
"""
# ---------------------------------------------------------------
# 步骤 0:获取最新 STATE + 新鲜度校验 + Failsafe
# ---------------------------------------------------------------
state = self.proxy.get_latest_state()
# ---- 新鲜度校验(协议 §3.8.1) ----
if self.state not in (MissionState.INIT, MissionState.FINISHED):
if state is not None:
if not self.proxy.is_state_fresh(state):
age_us, _ = self.proxy.get_state_freshness(state)
age_s = (age_us or 0) / 1_000_000
logger.warning("⚠️ STATE 严重过期 (age=%.2fs),"
"本轮保守跳过", age_s)
return
else:
logger.warning("⚠️ 尚未收到 STATE,跳过本轮处理")
return
# ---- 特殊状态,等待航点 ----
if self.state == MissionState.WAITING_WAYPOINTS:
if self._waypoints_ready:
logger.info("✅ 航点已就绪,转入 INIT 状态")
self.state = MissionState.INIT
self._state_start_time = time.time()
else:
return
# ---- Failsafe 越权检查 ----
if self.state not in (MissionState.INIT, MissionState.FINISHED):
if state is not None:
mode = state["drone"].get("mode", "")
if mode not in self.AUTONOMOUS_MODES:
logger.warning(
"\n⚠️ [Failsafe] 飞控模式被切为: %s!"
"人工接管,熔断任务!", mode
)
self.state = MissionState.FINISHED
raise FailsafeTriggered(
f"安全员接管,当前模式: {mode}"
)
# ---- 无 STATE 时仅 INIT 可容忍 ----
if state is None:
if self.state == MissionState.INIT:
logger.info("[INIT] 等待 Router 状态推送...")
return
# ---------------------------------------------------------------
# 步骤 1:更新本地位置估计
# ---------------------------------------------------------------
self._update_local_position(state)
# ---------------------------------------------------------------
# 步骤 2:状态机分支
# ---------------------------------------------------------------
try:
if self.state == MissionState.INIT:
self._handle_init(state)
elif self.state == MissionState.ARMING:
self._handle_arming(state)
elif self.state == MissionState.TAKEOFF:
self._handle_takeoff(state)
elif self.state == MissionState.NAVIGATING:
self._handle_navigating(state)
elif self.state == MissionState.HOLD_TASK:
self._handle_hold_task(state, target_detected)
elif self.state == MissionState.VISUAL_TRACKING:
self._handle_visual_tracking(state, target_detected)
elif self.state == MissionState.RETURNING:
self._handle_returning(state)
elif self.state == MissionState.LANDING:
self._handle_landing(state)
elif self.state == MissionState.HOLD_FINAL:
self._handle_hold_final(state)
except FailsafeTriggered:
raise
except Exception as e:
logger.error("状态机异常 (state=%s): %s", self.state.name, e,
exc_info=True)
# ================================================================
# 位置估计
# ================================================================
def _update_local_position(self, state):
"""通过速度积分更新本地 NED 位置估计"""
try:
drone = state["drone"]
now = time.time()
if not self._home_set:
self._est_x = 0.0
self._est_y = 0.0
self._pos_time = now
self._home_set = True
logger.info("[位置] 初始位置记录完成")
dt = now - self._pos_time
if 0 < dt < self.MAX_POSITION_DT:
vx = drone.get("vx", 0.0)
vy = drone.get("vy", 0.0)
self._est_x += vx * dt
self._est_y += vy * dt
# dt ≥ MAX_POSITION_DT 时跳过积分防跳变
self._pos_time = now
except (KeyError, TypeError) as e:
logger.warning("[位置] 更新异常: %s", e)
def _get_current_z(self, state):
"""从 STATE 获取当前 NED Z(负值向上)"""
try:
return -state["drone"].get("alt_rel", 0.0)
except (KeyError, TypeError):
return self.target_altitude
# ---- 日志节流辅助 ----
def _throttle_log(self, key, interval, msg, *args, level=logging.INFO):
"""节流日志:同一 key 在 interval 秒内只输出一次"""
now = time.time()
last = self._last_throttle_time.get(key, 0)
if now - last >= interval:
self._last_throttle_time[key] = now
logger.log(level, msg, *args)
def _throttle_log_on_change(self, key, current_value, msg, *args,
level=logging.INFO):
"""状态变化日志:仅当 current_value 与上次记录不同时输出一次"""
last = self._last_throttle_state.get(key, object())
if last != current_value:
self._last_throttle_state[key] = current_value
logger.log(level, msg, *args)
# ================================================================
# 状态处理器
# ================================================================
# ---- 初始化 ----
def _handle_init(self, state):
"""
[INIT] 等待飞控连接,判断是否需要执行完整起飞序列
三种分支:
A) 已解锁 + OFFBOARD → 直接跳 NAVIGATING
B) 未解锁 → 发 ARM 指令
C) 已解锁但非 OFFBOARD → 进入 ARMING 等待后续 TAKEOFF 序列
"""
try:
drone = state["drone"]
armed = drone.get("armed", False)
mode = drone.get("mode", "")
alt_rel = drone.get("alt_rel", 0.0)
home = state.get("home", {})
logger.info("[INIT] mode=%s armed=%s alt_rel=%.1fm",
mode, armed, alt_rel)
if not home.get("valid", False):
logger.warning("[INIT] home.valid=false,等待起飞点有效...")
return
# A) 已在 OFFBOARD + 已解锁 → 直接巡航
if armed and mode == "OFFBOARD":
logger.info("[INIT] 已在 OFFBOARD,直接进入航点巡航")
self.state = MissionState.NAVIGATING
self.current_wp_index = 0
self._set_current_target_from_wp(0)
return
# B) 未解锁
if not armed:
logger.info("[INIT] 执行解锁...")
ok, ack = self.proxy.send_command("ARM")
if ok:
self.state = MissionState.ARMING
self._state_start_time = time.time()
else:
logger.error("[INIT] ARM 失败: %s", ack)
return
# C) 已解锁但非 OFFBOARD
if armed and mode != "OFFBOARD":
logger.info("[INIT] 已解锁但 mode=%s,进入起飞序列", mode)
self.state = MissionState.ARMING
self._state_start_time = time.time()
except (KeyError, TypeError) as e:
logger.warning("[INIT] 解析异常: %s", e)
# ---- 解锁 ----
def _handle_arming(self, state):
"""
[ARMING] 等待解锁确认,成功后进入 TAKEOFF
"""
try:
armed = state["drone"].get("armed", False)
home = state.get("home", {})
if not armed:
elapsed = time.time() - self._state_start_time
if elapsed > self.ARM_TIMEOUT:
logger.warning("[ARMING] 超时 (%.1fs),重试 ARM", elapsed)
self.proxy.send_command("ARM")
self._state_start_time = time.time()
else:
logger.info("[ARMING] 等待解锁... (%.1fs)", elapsed)
return
if not home.get("valid", False):
logger.warning("[ARMING] home.valid=false,等待起飞点有效...")
return
# ---- 解锁成功 → 起飞 ----
takeoff_alt = -self.target_altitude # 正值(协议 altitude 为正)
logger.info("\n[解锁成功] 发送起飞指令 (alt=%.1fm)", takeoff_alt)
ok, ack = self.proxy.send_waypoint(
lat=state["home"]["lat"],
lon=state["home"]["lon"],
alt=takeoff_alt,
alt_frame="RELATIVE",
speed=3.0, action="TAKEOFF",
yaw=state["drone"]["yaw"],
acceptance_radius=0.5,
is_last=True
)
if ok:
self.state = MissionState.TAKEOFF
self._state_start_time = time.time()
else:
logger.warning("[TAKEOFF] WAYPOINT 失败: %s,尝试 SETPOINT 爬升", ack)
ok2, _ = self.proxy.send_setpoint(
x=0, y=0, z=self.target_altitude, yaw=0.0,
control_mode="POSITION",
)
if ok2:
self.state = MissionState.TAKEOFF
self._state_start_time = time.time()
else:
logger.error("[TAKEOFF] 降级爬升也失败")
except (KeyError, TypeError) as e:
logger.warning("[ARMING] 异常: %s", e)
# ---- 起飞 ----
def _handle_takeoff(self, state):
"""
[TAKEOFF] 持续发送 POSITION SETPOINT 爬升,到达后进入 OFFBOARD
流程:
① 每帧发送 POSITION 悬停/爬升 SETPOINT(预热 + 保持流)
② 高度到达目标 90% → 发送 OFFBOARD 指令
③ 模式切换确认 → 跳转到 NAVIGATING
"""
try:
drone = state["drone"]
alt_rel = drone.get("alt_rel", 0.0)
mode = drone.get("mode", "")
target_up = -self.target_altitude # 正值
# ---- ① 持续发送 POSITION SETPOINT(预热 OFFBOARD 流) ----
# 通过 MissionManager 自有 proxy 发送,此阶段 UAVControlLoop
# 的 20Hz 线程会被 run_mission.py 关闭,不会冲突。
# NED 坐标系:x=0,y=0,z=target_altitude 表示飞到正上方目标高度
self.proxy.send_setpoint(
x=0, y=0, z=self.target_altitude, yaw=0.0,
control_mode="POSITION",
)
# ---- ② 到达目标高度 → 进入 OFFBOARD ----
reached_alt = alt_rel >= target_up * 0.9
if reached_alt and mode != "OFFBOARD":
logger.info("[TAKEOFF] 高度 %.1f/%.1f,切换 OFFBOARD...",
alt_rel, target_up)
# 先预热悬停(协议 §3.3.1 ③→④)
self.proxy.send_setpoint(
x=0, y=0, z=-alt_rel, yaw=0.0,
control_mode="POSITION",
)
time.sleep(0.2)
ok, ack = self.proxy.send_command("OFFBOARD")
if ok:
# 等待模式切换
for _ in range(int(self.OFFBOARD_TIMEOUT / 0.1)):
st = self.proxy.get_latest_state()
if st and st["drone"]["mode"] == "OFFBOARD":
logger.info("[TAKEOFF] 已进入 OFFBOARD")
break
time.sleep(0.1)
else:
logger.warning("[TAKEOFF] OFFBOARD 切换超时")
else:
logger.error("[TAKEOFF] OFFBOARD 指令失败: %s", ack)
# ---- ③ 高度到位 + OFFBOARD 模式 → 跳转巡航 ----
if reached_alt and mode == "OFFBOARD":
logger.info("\n[起飞完成] 高度 %.1fm,进入航点巡航", alt_rel)
self.state = MissionState.NAVIGATING
self.current_wp_index = 0
self._set_current_target_from_wp(0)
return
# ---- 爬升超时保护 ----
elapsed = time.time() - self._state_start_time
if elapsed > self.TAKEOFF_TIMEOUT:
logger.warning("[TAKEOFF] 超时 (%.1fs),强行进入巡航", elapsed)
self.state = MissionState.NAVIGATING
self.current_wp_index = 0
self._set_current_target_from_wp(0)
return
# 节流:每 3 秒输出一次高度进度,不刷屏
self._throttle_log("takeoff", 3.0,
"[起飞中] alt=%.1f/%.1f mode=%s 耗时=%.1fs",
alt_rel, target_up, mode, elapsed)
except (KeyError, TypeError) as e:
logger.warning("[TAKEOFF] 异常: %s", e)
# ---- 航点导航 ----
def _handle_navigating(self, state):
"""
[NAVIGATING] 每帧发送 POSITION SETPOINT 前往当前航点
到达判定后推进索引;所有航点飞完后根据 return_to_home
决定返航或悬停。
"""
if not self.waypoints or self.current_wp_index >= len(self.waypoints):
self._finish_navigation()
return
wp = self.waypoints[self.current_wp_index]
cmd = wp.get("command", "WAYPOINT")
# 特殊航点分支选择
# 起飞航点
if cmd == "TAKEOFF":
# 飞到原点 + 目标高度
self.proxy.send_setpoint(
x=0, y=0, z=wp.get("z", self.target_altitude),
yaw=wp.get("yaw", 0.0),control_mode="POSITION",
)
current_z = self._get_current_z(state)
target_z = wp.get("z", self.target_altitude)
if abs(current_z - target_z) < 0.5:
self.current_wp_index += 1
return
# 降落航点
if cmd == "LAND":
logger.info("🛬 执行降落")
self.proxy.send_command("LAND")
self.state = MissionState.LANDING
return
wp_x = wp["x"]
wp_y = wp["y"]
wp_z = wp.get("z", self.target_altitude)
wp_yaw = wp.get("yaw", 0.0)
# ---- 发送 POSITION SETPOINT(通过自有 proxy) ----
# 此 SETPOINT 让 PX4 飞向目标航点
self.proxy.send_setpoint(
x=wp_x, y=wp_y, z=wp_z, yaw=wp_yaw,
control_mode="POSITION",
)
# ---- 共享目标 ----
self.current_target = {"x": wp_x, "y": wp_y, "z": wp_z, "yaw": wp_yaw}
# ---- 到达判定 ----
current_z = self._get_current_z(state)
dist = _dist_3d(self._est_x, self._est_y, current_z,
wp_x, wp_y, wp_z)
if dist < self.arrival_radius:
logger.info("\n✅ [到达航点 %d/%d] dist=%.2fm",
self.current_wp_index + 1, len(self.waypoints), dist)
self.state = MissionState.HOLD_TASK
self.hold_start_time = time.time()
else:
# 节流:每 5 秒输出一次巡航进度,不刷屏
self._throttle_log("navigating", 5.0,
"[巡航中] 航点 [%d/%d] 距离=%.2fm",
self.current_wp_index + 1,
len(self.waypoints), dist)
def _finish_navigation(self):
"""所有航点飞完后的跳转"""
if self.return_to_home:
logger.info("\n[巡航完成] 返回起始点...")
self.state = MissionState.RETURNING
self.current_target = {"x": 0, "y": 0,
"z": self.target_altitude, "yaw": 0}
else:
logger.info("\n[巡航完成] 末端悬停...")
self.state = MissionState.HOLD_FINAL
self._state_start_time = time.time()
# ---- 悬停搜索 ----
def _handle_hold_task(self, state, target_detected):
"""
[HOLD_TASK] 抵达航点后的悬停搜索
策略:
- 检测到目标 → VISUAL_TRACKING
- 超时无目标 → 下一个航点
- 持续发送 POSITION SETPOINT 悬停
"""
# ---- 发现目标 ----
if target_detected:
logger.info("\n🎯 [目标锁定] 切入视觉追踪状态!")
self.state = MissionState.VISUAL_TRACKING
self.tracking_start_time = time.time()
self._target_lost_start = None
self.tracking_completed = False
return
# ---- 保持当前位置悬停 ----
wp = self.waypoints[self.current_wp_index]
self.proxy.send_setpoint(
x=wp["x"], y=wp["y"],
z=wp.get("z", self.target_altitude),
yaw=wp.get("yaw", 0),
control_mode="POSITION",
)
self.current_target = {
"x": wp["x"], "y": wp["y"],
"z": wp.get("z", self.target_altitude),
"yaw": wp.get("yaw", 0),
}
# ---- 超时判定 ----
elapsed = time.time() - self.hold_start_time
if elapsed >= self.hold_duration:
logger.info("\n[未发现目标] 搜寻超时 (%.1fs),前往下一航点",
self.hold_duration)
self.current_wp_index += 1
self.state = MissionState.NAVIGATING
else:
# 静默悬停检索,不刷屏;仅每 3 秒轻量心跳
self._throttle_log("hold_task", 3.0,
"[检索中] 航点 %d 剩余 %.1fs",
self.current_wp_index + 1,
self.hold_duration - elapsed)
# ---- 视觉追踪 ----
def _handle_visual_tracking(self, state, target_detected):
"""
[VISUAL_TRACKING] 🎯 完全让渡控制权给 UAVControlLoop
【核心设计】
MissionManager 在此状态下 **不发送任何 SETPOINT**。
UAVControlLoop (20Hz 独立线程) 通过其自有 proxy 发送
VELOCITY SETPOINT 进行视觉闭环追踪。
本模块仅负责追踪退出条件的判定和状态切换。
【退出条件】
1. self.tracking_completed == True(外部模块设置)
2. 目标丢失超过 target_lost_timeout 秒
3. 追踪总时长超过 tracking_max_duration(兜底)
"""
# ---- 不发送任何 SETPOINT — 控制权已让渡 ----
tracking_elapsed = time.time() - self.tracking_start_time
# ---- 兜底超时 ----
if tracking_elapsed > self.tracking_max_duration:
logger.info("\n⏰ [追踪超时] %.1fs ≥ %.1fs",
tracking_elapsed, self.tracking_max_duration)
self._exit_tracking()
return
# ---- 外部完成信号 ----
if self.tracking_completed:
logger.info("\n✅ [追踪完成] 外部模块报告完成,返回巡航")
self.tracking_completed = False
self._exit_tracking()
return
# ---- 目标丢失超时 ----
if not target_detected:
if self._target_lost_start is None:
self._target_lost_start = time.time()
else:
lost_elapsed = time.time() - self._target_lost_start
if lost_elapsed >= self.target_lost_timeout:
logger.info("\n⏰ [丢失超时] %.1fs,放弃追踪",
lost_elapsed)
self._exit_tracking()
return
# 节流:每 2 秒输出一次丢失状态(不刷屏)
self._throttle_log("track_lost", 2.0,
"[追踪中] 丢失 %.1fs/%.1fs",
lost_elapsed if self._target_lost_start
else 0.0,
self.target_lost_timeout)
else:
# 目标重新出现时,单次日志(事件驱动)
self._throttle_log_on_change("track_lost_clear",
self._target_lost_start is not None,
"[追踪中] 目标恢复")
self._target_lost_start = None
# 节流:每 5 秒输出一次追踪心跳,20Hz 静默
self._throttle_log("track_heartbeat", 5.0,
"[追踪中] 进行中... %.1fs", tracking_elapsed)
def _exit_tracking(self):
"""退出 VISUAL_TRACKING 的统一出口"""
self.current_wp_index += 1
self.state = MissionState.NAVIGATING
self._target_lost_start = None
logger.info("[状态切换] VISUAL_TRACKING → NAVIGATING,"
"前往航点 %d", self.current_wp_index + 1)
# ---- 返航 ----
def _handle_returning(self, state):
"""
[RETURNING] 发送 POSITION SETPOINT 到起始点 (0,0,z)
"""
self.proxy.send_setpoint(
x=0, y=0, z=self.target_altitude, yaw=0,
control_mode="POSITION",
)
self.current_target = {"x": 0, "y": 0,
"z": self.target_altitude, "yaw": 0}
current_z = self._get_current_z(state)
dist = _dist_3d(self._est_x, self._est_y, current_z,
0.0, 0.0, self.target_altitude)
# 节流:每 3 秒输出一次返航距离,不刷屏
self._throttle_log("returning", 3.0,
"[返航中] 距离=%.2fm", dist)
if dist < self.arrival_radius:
logger.info("\n[到达] 已返回起始点,发送 LAND 指令...")
ok, ack = self.proxy.send_command("LAND")
if ok:
self.state = MissionState.LANDING
else:
logger.warning("[返航] LAND 失败: %s", ack)
# ---- 降落 ----
def _handle_landing(self, state):
"""
[LANDING] 监控 disarm 状态判断着陆
"""
try:
armed = state["drone"].get("armed", False)
alt_rel = state["drone"].get("alt_rel", 0.0)
# 节流:每 3 秒输出一次高度,不刷屏
self._throttle_log("landing", 3.0,
"[降落中] 高度=%.1fm 已解锁=%s",
alt_rel, not armed)
if not armed:
logger.info("\n[完结] 已着陆上锁,任务结束")
self.state = MissionState.FINISHED
except (KeyError, TypeError) as e:
logger.warning("[LANDING] 异常: %s", e)
# ---- 末端悬停 ----
def _handle_hold_final(self, state):
"""
[HOLD_FINAL] 末端悬停,保持最终航点位置
"""
if self.waypoints:
final_wp = self.waypoints[-1]
else:
final_wp = {"x": 0, "y": 0,
"z": self.target_altitude, "yaw": 0}
self.proxy.send_setpoint(
x=final_wp["x"], y=final_wp["y"],
z=final_wp.get("z", self.target_altitude),
yaw=final_wp.get("yaw", 0),
control_mode="POSITION",
)
self.current_target = {
"x": final_wp["x"], "y": final_wp["y"],
"z": final_wp.get("z", self.target_altitude),
"yaw": final_wp.get("yaw", 0),
}
# 节流:每 10 秒输出一次末端悬停状态,不刷屏
self._throttle_log("hold_final", 10.0,
"[挂起] 保持 (%.1f, %.1f, %.1f) 等待接管",
final_wp["x"], final_wp["y"],
final_wp.get("z", self.target_altitude))
# ================================================================
# 辅助
# ================================================================
def _set_current_target_from_wp(self, index):
"""从航点列表更新 current_target"""
if self.waypoints and index < len(self.waypoints):
wp = self.waypoints[index]
self.current_target = {
"x": wp["x"], "y": wp["y"],
"z": wp.get("z", self.target_altitude),
"yaw": wp.get("yaw", 0),
}
def set_waypoints(self, waypoints):
"""由外部调用,动态注入/更新航点列表"""
self.waypoints = list(waypoints)
self.current_wp_index = 0
self._waypoints_ready = True # 这个标志位结合 WAITING_WAYPOINTS 状态使用
logger.info("MissionManager 航点已更新,共 %d 个", len(waypoints))
# ================================================================
# 🚨 紧急停靠(外部调用)
# ================================================================
def emergency_stop(self):
"""
外部触发的紧急降落入口(可在任何状态下调用)
行为:
1. 发送 LAND 指令到飞控
2. 将状态机切换到 LANDING 状态
3. 记录警告日志(供事后分析)
注意:
- 此方法可在任何线程中调用(内部使用 proxy,proxy 本身是线程安全的)
- 调用后,主循环会检测到 state == LANDING 并执行降落逻辑
- 如果已经在 LANDING 状态,重复调用只会重新发送 LAND 指令
"""
logger.warning("🚨 [emergency_stop] 紧急降落触发!")
# 1. 发送 LAND 指令(不管当前状态如何,直接发)
ok, ack = self.proxy.send_command("LAND")
if ok:
logger.info("✅ LAND 指令已发送")
else:
logger.error(f"❌ LAND 指令发送失败: {ack}")
# 2. 状态机强制切换到 LANDING(让主循环接管后续降落逻辑)
self.state = MissionState.LANDING
# 3. 重置一些计时器,让降落逻辑从当前状态开始
self._state_start_time = time.time()