|
|
|
|
@ -3,14 +3,19 @@
|
|
|
|
|
任務執行模組
|
|
|
|
|
管理多架無人機的 GUIDED 模式飛行控制迴圈
|
|
|
|
|
|
|
|
|
|
設計:
|
|
|
|
|
設計 (閉環 / closed-loop):
|
|
|
|
|
- 每架無人機持有一個航點序列,逐點推進
|
|
|
|
|
- 各自到達就各自切換到下一個航點
|
|
|
|
|
- 事件驅動發送:航點切換時送一次,收到 send_result 才決定重送/前進
|
|
|
|
|
- 失敗策略 (b):單機重試 MAX_RETRY 次仍失敗 → 該機 fallback LOITER,其他機繼續
|
|
|
|
|
- 閉環發送:以固定週期「持續重送」當前目標 setpoint(GUIDED 位置目標冪等),
|
|
|
|
|
掉包會在下個週期自癒;換點完全靠 GPS 到達判定驅動,不依賴通訊 ACK
|
|
|
|
|
- send_result(echo 驗證)僅作觀測/UI 顯示,不影響飛行控制:
|
|
|
|
|
驗證失敗不會 fallback LOITER,指令其實已送達飛控,交給 GPS 續飛
|
|
|
|
|
- Rendezvous barrier:在指定 wp index 等所有活著的機到齊才一起推進,有 timeout 保護
|
|
|
|
|
- 用 QTimer 驅動到達判定,在 Qt 主線程執行
|
|
|
|
|
- 暫停 = 停止到達判定 + 停止新指令
|
|
|
|
|
|
|
|
|
|
背景:adapter 的 pos_global_int service 被序列化 + 阻塞等 msg87 echo,實飛慢速鏈路下
|
|
|
|
|
多機會只有一台在 client timeout 內拿到回應。閉環設計繞過此瓶頸(見專案記憶)。
|
|
|
|
|
"""
|
|
|
|
|
import asyncio
|
|
|
|
|
import math
|
|
|
|
|
@ -42,7 +47,7 @@ class DroneTask:
|
|
|
|
|
"""單架無人機的任務資料"""
|
|
|
|
|
__slots__ = (
|
|
|
|
|
'drone_id', 'sysid', 'waypoints', 'wp_index', 'done',
|
|
|
|
|
'sent_current_wp', 'fail_count', 'status', 'waiting_since',
|
|
|
|
|
'last_sent_at', 'fail_count', 'status', 'waiting_since',
|
|
|
|
|
'entered_radius_at', 'last_log_at',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@ -52,7 +57,8 @@ class DroneTask:
|
|
|
|
|
self.waypoints = waypoints
|
|
|
|
|
self.wp_index = 0
|
|
|
|
|
self.done = len(waypoints) == 0
|
|
|
|
|
self.sent_current_wp = False
|
|
|
|
|
# 上次送出當前目標的 monotonic 時間;0.0 = 尚未送過(下個 tick 立即送)
|
|
|
|
|
self.last_sent_at = 0.0
|
|
|
|
|
self.fail_count = 0
|
|
|
|
|
self.status = TaskStatus.NORMAL
|
|
|
|
|
self.waiting_since = 0.0 # monotonic time 進入 WAITING_AT_BARRIER 的瞬間
|
|
|
|
|
@ -85,7 +91,7 @@ class MissionExecutor(QObject):
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
MAX_RETRY = 3
|
|
|
|
|
MAX_RETRY = 3 # 已停用:閉環下 echo 驗證失敗不再累計切 LOITER(保留常數以防外部引用)
|
|
|
|
|
|
|
|
|
|
drone_waypoint_reached = pyqtSignal(str, int, int) # (drone_id, wp_index, total)
|
|
|
|
|
task_status_changed = pyqtSignal(str, str, str) # (drone_id, status, message)
|
|
|
|
|
@ -95,12 +101,16 @@ class MissionExecutor(QObject):
|
|
|
|
|
arrival_radius=4.0, tick_rate_hz=2.0,
|
|
|
|
|
barrier_timeout_sec=20.0,
|
|
|
|
|
hover_stable_sec=2.0,
|
|
|
|
|
progress_log_interval_sec=3.0):
|
|
|
|
|
progress_log_interval_sec=3.0,
|
|
|
|
|
resend_interval_sec=2.5):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.sender = sender
|
|
|
|
|
self.drone_gps = drone_gps
|
|
|
|
|
self.monitor = monitor # 用於失敗 fallback 到 LOITER
|
|
|
|
|
self.monitor = monitor # 保留:僅供外部手動 fallback 使用,閉環下不自動切 LOITER
|
|
|
|
|
self.arrival_radius = arrival_radius
|
|
|
|
|
# 閉環重送週期:每隔這麼久重送一次當前目標 setpoint(掉包自癒)。
|
|
|
|
|
# 注意:需 > N架 × client_timeout,否則多機序列化會塞不完(見 command_sender)。
|
|
|
|
|
self.resend_interval_sec = resend_interval_sec
|
|
|
|
|
self.barrier_timeout_sec = barrier_timeout_sec
|
|
|
|
|
# hover-stable 判定:進入 radius 後須穩定停留 hover_stable_sec 秒才算到達,
|
|
|
|
|
# 容忍 GPS 抖動跨越邊界(用 radius * 1.5 作 hysteresis)
|
|
|
|
|
@ -152,6 +162,7 @@ class MissionExecutor(QObject):
|
|
|
|
|
f"共 {total_wps} 個航點, "
|
|
|
|
|
f"到達半徑={self.arrival_radius}m (hover-stable {self.hover_stable_sec}s), "
|
|
|
|
|
f"tick 週期={self._interval_ms}ms, "
|
|
|
|
|
f"重送週期={self.resend_interval_sec}s (閉環,GPS 驅動換點), "
|
|
|
|
|
f"barrier timeout={self.barrier_timeout_sec}s, "
|
|
|
|
|
f"{rv_info}",
|
|
|
|
|
)
|
|
|
|
|
@ -249,19 +260,21 @@ class MissionExecutor(QObject):
|
|
|
|
|
# ---- Phase 2: barrier 釋放檢查 ----
|
|
|
|
|
self._check_barriers(now)
|
|
|
|
|
|
|
|
|
|
# ---- Phase 3: 發送未送過的目標 ----
|
|
|
|
|
# ---- Phase 3: 週期性重送當前目標(閉環)----
|
|
|
|
|
# 每 resend_interval_sec 重送一次目前 wp 的 setpoint,不靠 send_result 決定前進。
|
|
|
|
|
# GUIDED 位置目標冪等,重送安全;掉包會在下個週期自癒。first send: last_sent_at=0 → 立即送。
|
|
|
|
|
for task in self.tasks.values():
|
|
|
|
|
if task.done or task.status in (
|
|
|
|
|
TaskStatus.FALLBACK_LOITER, TaskStatus.WAITING_AT_BARRIER
|
|
|
|
|
):
|
|
|
|
|
continue
|
|
|
|
|
if task.sent_current_wp:
|
|
|
|
|
continue
|
|
|
|
|
target = task.current_target
|
|
|
|
|
if target is None:
|
|
|
|
|
continue
|
|
|
|
|
if now - task.last_sent_at < self.resend_interval_sec:
|
|
|
|
|
continue
|
|
|
|
|
tgt_lat, tgt_lon, tgt_alt = target
|
|
|
|
|
task.sent_current_wp = True
|
|
|
|
|
task.last_sent_at = now
|
|
|
|
|
self.sender.send_position_global(
|
|
|
|
|
task.drone_id, task.sysid, tgt_lat, tgt_lon, tgt_alt
|
|
|
|
|
)
|
|
|
|
|
@ -293,12 +306,14 @@ class MissionExecutor(QObject):
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _advance_waypoint(self, task, arrived_distance):
|
|
|
|
|
"""把 task 推進一個航點,重置發送旗標。不處理 barrier 邏輯。"""
|
|
|
|
|
"""把 task 推進一個航點,重置發送計時。不處理 barrier 邏輯。"""
|
|
|
|
|
task.wp_index += 1
|
|
|
|
|
task.sent_current_wp = False
|
|
|
|
|
task.last_sent_at = 0.0 # 新 wp 下個 tick 立即送
|
|
|
|
|
task.fail_count = 0
|
|
|
|
|
task.entered_radius_at = 0.0
|
|
|
|
|
task.last_log_at = 0.0
|
|
|
|
|
if task.status == TaskStatus.RETRYING:
|
|
|
|
|
task.status = TaskStatus.NORMAL # 清掉上個 wp 殘留的「送出未驗證」狀態
|
|
|
|
|
if task.wp_index >= task.total_waypoints:
|
|
|
|
|
task.done = True
|
|
|
|
|
self.drone_waypoint_reached.emit(
|
|
|
|
|
@ -369,42 +384,46 @@ class MissionExecutor(QObject):
|
|
|
|
|
# ------------------------------------------------------------------ 結果回呼
|
|
|
|
|
|
|
|
|
|
def _on_send_result(self, drone_id, sysid, success, message):
|
|
|
|
|
"""Ros2CommandSender.send_result 的 slot"""
|
|
|
|
|
"""
|
|
|
|
|
Ros2CommandSender.send_result 的 slot(閉環:僅觀測,不影響飛行控制)。
|
|
|
|
|
|
|
|
|
|
指令在 adapter 端等 echo 前就已送達飛控,echo 驗證失敗不代表沒送到;
|
|
|
|
|
因此這裡不再累計失敗切 LOITER,只更新 UI 狀態。真正的前進由 GPS 到達判定驅動,
|
|
|
|
|
重送由 Phase 3 的週期性重送負責。
|
|
|
|
|
"""
|
|
|
|
|
task = self.tasks.get(drone_id)
|
|
|
|
|
if task is None or task.done:
|
|
|
|
|
return
|
|
|
|
|
# 若已在 barrier 等待,舊指令的遲到回應不要再觸發重試/fallback
|
|
|
|
|
# 若已在 barrier 等待,舊指令的遲到回應不要再干擾狀態
|
|
|
|
|
if task.status == TaskStatus.WAITING_AT_BARRIER:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if success:
|
|
|
|
|
if task.fail_count > 0 or task.status == TaskStatus.RETRYING:
|
|
|
|
|
task.status = TaskStatus.NORMAL
|
|
|
|
|
self.task_status_changed.emit(drone_id, task.status.value, "recovered")
|
|
|
|
|
self.task_status_changed.emit(drone_id, task.status.value, "send verified")
|
|
|
|
|
task.fail_count = 0
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# echo 驗證失敗:僅記錄 + UI 提示,不改變飛行(交給 GPS 續飛、下個週期重送)
|
|
|
|
|
task.fail_count += 1
|
|
|
|
|
_log("WARN", f"{drone_id} 發送失敗 {task.fail_count}/{self.MAX_RETRY}: {message}")
|
|
|
|
|
|
|
|
|
|
if task.fail_count < self.MAX_RETRY:
|
|
|
|
|
task.status = TaskStatus.RETRYING
|
|
|
|
|
task.sent_current_wp = False # 下個 tick 會重送
|
|
|
|
|
self.task_status_changed.emit(
|
|
|
|
|
drone_id, task.status.value,
|
|
|
|
|
f"retry {task.fail_count}/{self.MAX_RETRY}: {message}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
task.status = TaskStatus.FALLBACK_LOITER
|
|
|
|
|
self.task_status_changed.emit(
|
|
|
|
|
drone_id, task.status.value,
|
|
|
|
|
f"fallback LOITER after {self.MAX_RETRY} fails: {message}"
|
|
|
|
|
)
|
|
|
|
|
_log("ERROR", f"{drone_id} 連續失敗 {self.MAX_RETRY} 次,切換至 LOITER")
|
|
|
|
|
self._fallback_to_loiter(drone_id)
|
|
|
|
|
task.status = TaskStatus.RETRYING # 純觀測用途,Phase 1/3 不會因此停飛
|
|
|
|
|
_log(
|
|
|
|
|
"WARN",
|
|
|
|
|
f"{drone_id} 送出未驗證 x{task.fail_count}(指令已送達飛控,由 GPS 續飛): {message}",
|
|
|
|
|
)
|
|
|
|
|
self.task_status_changed.emit(
|
|
|
|
|
drone_id, task.status.value,
|
|
|
|
|
f"send unverified x{task.fail_count} (GPS 續飛): {message}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _fallback_to_loiter(self, drone_id):
|
|
|
|
|
"""用 monitor.set_mode 切 LOITER。set_mode 是 coroutine,透過 event loop 派送。"""
|
|
|
|
|
"""
|
|
|
|
|
用 monitor.set_mode 切 LOITER。set_mode 是 coroutine,透過 event loop 派送。
|
|
|
|
|
|
|
|
|
|
閉環改版後不再由 _on_send_result 自動呼叫(送指令失敗不代表沒送到);
|
|
|
|
|
保留此方法供外部(例如 GUI 手動安全按鈕)需要時使用。
|
|
|
|
|
"""
|
|
|
|
|
if self.monitor is None:
|
|
|
|
|
_log("WARN", f"無 monitor,無法將 {drone_id} 切換至 LOITER")
|
|
|
|
|
return
|
|
|
|
|
|