14 Commits
Author SHA1 Message Date
chiyu1468 38d8d23514 Merge branch 'chiyu' 2026-04-28 15:45:56 +08:00
chiyu1468 cc00427848 # (update)
1. serialManager.py - AT command 支援與架構重整
2. mainOrchestrator.py - serial 介面功能開啟
2026-04-28 15:41:50 +08:00
chiyu1468 7b7e02a9e7 (update) serialManager: 增加 AT 指令處理功能與串口寫入支持,更新版本號至 0.80 2026-04-27 15:12:38 +08:00
ken910606 be52c4bd22 Update GUI 2.0.8 group selected list 2026-04-24 18:27:16 +08:00
ken910606 b9d6e0e2e0 Update GUI 2.0.7 group selected list 2026-04-23 03:24:13 +08:00
chiyu1468 06463d71bc (modify) serialManager 架構調整 詳見改版記錄 2026-04-20 18:02:50 +08:00
chiyu1468 7ce094d211 (temp) 整理檔案與註解 2026-04-20 15:02:47 +08:00
wenchunandClaude Opus 4.6 0edc477df8 feat(mission): virtual-leader path following + 固定領隊 + barrier
- mission_planner: Leader-Follower 改用 arc-length 參數化(virtual
  leader),_build_center_path 以同心圓弧連接直線段,銳角退化成 hover
  單點並在該點放 rendezvous barrier,避免暴衝與編隊交叉
- mission_planner: rank 改以輸入順序為準,不再對投影排序,避免浮點噪音
  導致 run-to-run leader 漂移
- mission_group / gui: 新增 leader_drone_id 與「領隊」下拉選單,handle_
  route_confirmed 把指定領隊重排到 rank 0,整個縱隊順序固定如軍隊行進
- mission_executor: rendezvous barrier 釋放邏輯、fallback LOITER、send
  retry;加 barrier_timeout 保護
- command_sender / communication / navigation: 配合 setpoint 送達與
  Ros2 bridge 更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:13:31 +08:00
wenchun 5c0d21fc1d Merge remote-tracking branch 'origin/master' into wenchun 2026-04-14 15:23:13 +08:00
wenchunandClaude Opus 4.6 3483864a2e chore: 移除無關的模擬資料檔與 README 過時段落
刪除歷史 merge 夾帶進來的 obstacles_data.mat 與 uav_simulation_data.mat
(屬於另一個研究的資料,與本專案無關),並移除 README 末尾 0328 的
test_transform / close_loop 敘述。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:15:15 +08:00
wenchunandClaude Opus 4.6 ae80820419 chore: gitignore notes/ 資料夾以收納本地設計筆記
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 15:15:10 +08:00
ken910606 fc00ecb762 Update GUI 2.0.6 components and longCommand 2026-04-14 15:09:25 +08:00
wenchun 32e1ce8fb2 Merge remote-tracking branch 'origin/master' into wenchun 2026-04-14 13:54:08 +08:00
wenchun 33d6d59139 chore: ignore CLAUDE.md, 本地筆記與 eeprom dump 2026-04-14 13:53:40 +08:00
28 changed files with 4924 additions and 825 deletions
+6
View File
@@ -26,3 +26,9 @@ Makefile
**/*.pyc **/*.pyc
**/*.pyo **/*.pyo
**/.cursor/ **/.cursor/
CLAUDE.md
# 本地筆記與硬體 dump
my_env_notes.md
notes/
**/eeprom.bin
-7
View File
@@ -1,7 +1,5 @@
這是天雷系統的專案 這是天雷系統的專案
=== ===
## 功能簡介 ## 功能簡介
1. mavlink 多對多支援平台 1. mavlink 多對多支援平台
@@ -97,8 +95,3 @@ python gui.py
N. logs 是執行時期的記錄檔 N. logs 是執行時期的記錄檔
=== ===
簡述0328
主要是透過test_transform這隻程式執行fdm_switch_example_one_with_remote_forwarding這個副函式,透過UDP將封包轉接到Matlab,並在Matlab進行演算法迭代後,打包JSON檔,並再次透過port口傳回封包,傳回來的封包會在close_loop這隻程式被解析,並提取其中有關velocity的資料儲存成變數,以mavlink的方式打入Ardupilot,以實現封閉迴路的構思。
Binary file not shown.
+88 -13
View File
@@ -3,10 +3,16 @@
指令發送模組 指令發送模組
負責將目標位置轉成具體的通訊指令發送到飛控 負責將目標位置轉成具體的通訊指令發送到飛控
現階段: MavlinkSender (直接 pymavlink 發送) 目前使用 Ros2CommandSender,經 fc_network_adapter 的
未來: 替換為 ROS2Sender (發到 ROS2 topic,由 fc_network_adapter 轉發) /fc_network/vehicle/pos_global_int service 發送。
MavlinkSender 保留作為直連 SITL 的 fallback 工具,但已不是預設路徑。
""" """
import asyncio
import traceback
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from PyQt6.QtCore import QObject, pyqtSignal
from pymavlink import mavutil from pymavlink import mavutil
@@ -14,11 +20,12 @@ class CommandSender(ABC):
"""指令發送抽象介面""" """指令發送抽象介面"""
@abstractmethod @abstractmethod
def send_position_global(self, sysid, lat, lon, alt): def send_position_global(self, drone_id, sysid, lat, lon, alt):
""" """
發送全球座標位置指令 發送全球座標位置指令
Args: Args:
drone_id: GUI 用的 drone 識別字串 (如 's0_11')
sysid: 目標無人機的 MAVLink system ID sysid: 目標無人機的 MAVLink system ID
lat: 緯度 (度) lat: 緯度 (度)
lon: 經度 (度) lon: 經度 (度)
@@ -58,16 +65,8 @@ class MavlinkSender(CommandSender):
self.mav = mavutil.mavlink_connection(connection_string) self.mav = mavutil.mavlink_connection(connection_string)
print(f"MavlinkSender 已建立連線: {connection_string}") print(f"MavlinkSender 已建立連線: {connection_string}")
def send_position_global(self, sysid, lat, lon, alt): def send_position_global(self, drone_id, sysid, lat, lon, alt):
""" """發送 SET_POSITION_TARGET_GLOBAL_INT。drone_id 未使用,保留為介面相容。"""
發送 SET_POSITION_TARGET_GLOBAL_INT
注意:
- coordinate_frame 使用 MAV_FRAME_GLOBAL_RELATIVE_ALT_INT
高度是相對起飛點的高度 (公尺)
- 如果 FormationPlanner 產出的 alt 是 AMSL 絕對高度,
需要在外部先減去起飛點高度再傳入
"""
self.mav.mav.set_position_target_global_int_send( self.mav.mav.set_position_target_global_int_send(
0, # time_boot_ms (不使用) 0, # time_boot_ms (不使用)
sysid, # target_system sysid, # target_system
@@ -88,3 +87,79 @@ class MavlinkSender(CommandSender):
self.mav.close() self.mav.close()
self.mav = None self.mav = None
print("MavlinkSender 已關閉") print("MavlinkSender 已關閉")
class Ros2CommandSender(QObject):
"""
透過 fc_network_apps.PositionTargetGlobalIntClient 送 goto 指令。
設計重點:
- send_position_global 是同步介面,立刻回傳。結果透過 send_result signal 回報
- 每台 drone 維護自己的 asyncio.Task pipeline,新的目標會 cancel 掉舊的 in-flight
- client 由 DroneMonitor.get_or_create_position_client 管理,per-drone 獨立節點
"""
# (drone_id, sysid, success, message)
send_result = pyqtSignal(str, int, bool, str)
DEFAULT_TIMEOUT_SEC = 2.0
def __init__(self, monitor, timeout_sec: float = DEFAULT_TIMEOUT_SEC):
super().__init__()
self._monitor = monitor
self._timeout_sec = timeout_sec
self._pending = {} # drone_id -> asyncio.Task
def send_position_global(self, drone_id, sysid, lat, lon, alt):
"""同步介面。立刻回,結果透過 send_result signal 回報。"""
# 取消該機上一個未完成的 task,避免舊指令覆蓋新指令
old = self._pending.get(drone_id)
if old is not None and not old.done():
old.cancel()
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = loop.create_task(
self._do_send(drone_id, int(sysid), float(lat), float(lon), float(alt))
)
self._pending[drone_id] = task
async def _do_send(self, drone_id, sysid, lat, lon, alt):
try:
client = self._monitor.get_or_create_position_client(drone_id)
if client is None:
self.send_result.emit(
drone_id, sysid, False,
"PositionTargetGlobalIntClient 無法建立"
)
return
result = await client.goto_position_only_async(
target_sysid=sysid,
target_compid=0,
latitude_deg=lat,
longitude_deg=lon,
alt=alt,
timeout_sec=self._timeout_sec,
)
self.send_result.emit(
drone_id, sysid, bool(result.success), str(result.message or "")
)
except asyncio.CancelledError:
# 被新的目標取代,正常狀況,不用回報
pass
except Exception as e:
traceback.print_exc()
self.send_result.emit(drone_id, sysid, False, f"exception: {e}")
def close(self):
"""取消所有未完成的 task。PositionTargetGlobalIntClient 由 DroneMonitor 負責 destroy。"""
for task in self._pending.values():
if not task.done():
task.cancel()
self._pending.clear()
print("Ros2CommandSender 已關閉")
+159 -21
View File
@@ -13,10 +13,11 @@ import sys
import os import os
import traceback import traceback
from pymavlink import mavutil from pymavlink import mavutil
from geometry_msgs.msg import Point, Vector3 from geometry_msgs.msg import Point, Vector3, Vector3Stamped, PoseWithCovarianceStamped
from sensor_msgs.msg import BatteryState, NavSatFix, Imu from sensor_msgs.msg import BatteryState, NavSatFix, Imu
from std_msgs.msg import Float64, String from std_msgs.msg import Float64, String
from mavros_msgs.msg import State, VfrHud from mavros_msgs.msg import State, VfrHud
from nav_msgs.msg import Odometry
from mavros_msgs.srv import CommandBool, CommandTOL from mavros_msgs.srv import CommandBool, CommandTOL
# 確保 src 目錄在 Python 路徑中(用於 fc_network_apps 導入) # 確保 src 目錄在 Python 路徑中(用於 fc_network_apps 導入)
@@ -36,6 +37,16 @@ except ImportError as e:
traceback.print_exc() traceback.print_exc()
CommandLongClient = None CommandLongClient = None
# 導入 fc_network_apps 的 navigation(PositionTargetGlobalInt / Offboard goto)
try:
from fc_network_apps.navigation import PositionTargetGlobalIntClient
except ImportError as e:
import traceback
print(f"⚠️ 警告: 無法導入 PositionTargetGlobalIntClient")
print(f" 错误: {e}")
traceback.print_exc()
PositionTargetGlobalIntClient = None
class DroneSignals(QObject): class DroneSignals(QObject):
update_signal = pyqtSignal(str, str, object) # (msg_type, drone_id, data) update_signal = pyqtSignal(str, str, object) # (msg_type, drone_id, data)
@@ -467,14 +478,20 @@ class DroneMonitor(Node):
self.serial_receivers = [] self.serial_receivers = []
# ================================================================================ # ================================================================================
# 【新增】初始化 CommandLongClient(持久化,不會每次調用都創建/銷毀) # 【新增】初始化 CommandLongClient 字典(為每個 drone 維護獨立的 client)
# ================================================================================ # ================================================================================
self.command_long_client = None # 改為為每個 drone 創建獨立的 client,避免多機並行時的競態條件
try: self.command_long_clients = {} # {drone_id: CommandLongClient}
self.command_long_client = CommandLongClient() self.client_lock = Lock() # 保護 clients 字典的訪問
except Exception as e: self.client_counter = 0 # 用於生成唯一的 client 節點名稱
print(f"⚠️ 警告: 無法初始化 CommandLongClient: {e}") self.executor = None # 將在 gui.py 中設置,用於添加新的 clients
self.command_long_client = None # ================================================================================
# ================================================================================
# PositionTargetGlobalIntClient 字典(per-drone,用於 Offboard goto)
# ================================================================================
self.position_target_clients = {} # {drone_id: PositionTargetGlobalIntClient}
self.pos_client_counter = 0
# ================================================================================ # ================================================================================
# 主题检测定时器 # 主题检测定时器
@@ -501,6 +518,58 @@ class DroneMonitor(Node):
return self.socket_id_mapping[original_socket_id] return self.socket_id_mapping[original_socket_id]
def get_or_create_client(self, drone_id):
"""為每個 drone 獲取或創建獨立的 CommandLongClient,避免競態條件"""
with self.client_lock:
if drone_id not in self.command_long_clients:
try:
# 生成唯一的 client 節點名稱
self.client_counter += 1
unique_name = f"cmd_long_client_{drone_id}_{self.client_counter}"
client = CommandLongClient(node_name=unique_name)
self.command_long_clients[drone_id] = client
print(f" ✓ 為 {drone_id} 創建新的 CommandLongClient (node={unique_name})")
# 將新 client 添加到主執行器(這樣它的回調才能被處理)
if self.executor:
self.executor.add_node(client)
print(f" ✓ 將 {drone_id} 的 client 添加到主執行器")
except TypeError:
# 舊版 CommandLongClient 不支持 node_name 參數,使用預設
client = CommandLongClient()
self.command_long_clients[drone_id] = client
print(f" ✓ 為 {drone_id} 創建新的 CommandLongClient (使用預設名稱)")
if self.executor:
self.executor.add_node(client)
print(f" ✓ 將 {drone_id} 的 client 添加到主執行器")
except Exception as e:
print(f"⚠️ 無法為 {drone_id} 創建 CommandLongClient: {e}")
return None
return self.command_long_clients[drone_id]
def get_or_create_position_client(self, drone_id):
"""為每個 drone 獲取或創建獨立的 PositionTargetGlobalIntClient。"""
if PositionTargetGlobalIntClient is None:
return None
with self.client_lock:
if drone_id not in self.position_target_clients:
try:
self.pos_client_counter += 1
unique_name = f"pos_target_client_{drone_id}_{self.pos_client_counter}"
client = PositionTargetGlobalIntClient(node_name=unique_name)
self.position_target_clients[drone_id] = client
print(f" ✓ 為 {drone_id} 創建新的 PositionTargetGlobalIntClient (node={unique_name})")
if self.executor:
self.executor.add_node(client)
print(f" ✓ 將 {drone_id} 的 position client 添加到主執行器")
except Exception as e:
print(f"⚠️ 無法為 {drone_id} 創建 PositionTargetGlobalIntClient: {e}")
return None
return self.position_target_clients[drone_id]
def scan_topics(self): def scan_topics(self):
topics = self.get_topic_names_and_types() topics = self.get_topic_names_and_types()
drone_pattern = re.compile(r'/fc_network/vehicle/(sys\d+)/(\w+)') drone_pattern = re.compile(r'/fc_network/vehicle/(sys\d+)/(\w+)')
@@ -520,8 +589,25 @@ class DroneMonitor(Node):
# 暂时所有 ROS2 topic 共享同一个 socket_id = 0 # 暂时所有 ROS2 topic 共享同一个 socket_id = 0
self.sys_to_socket_id[sys_id] = 0 self.sys_to_socket_id[sys_id] = 0
if not hasattr(self, f'drone_{sys_id}_subs'): subs_attr = f'drone_{sys_id}_subs'
if not hasattr(self, subs_attr):
self.setup_drone(sys_id) self.setup_drone(sys_id)
else:
# 檢查既有訂閱是否包含 position_ned,如果不包含就添加(兼容舊訂閱)
subs = getattr(self, subs_attr, {})
if isinstance(subs, dict) and 'position_ned' not in subs:
base_topic = f'/fc_network/vehicle/{sys_id}'
try:
pos_ned_sub = self.create_subscription(
Odometry,
f'{base_topic}/position_ned',
lambda msg, sid=sys_id: self.position_ned_callback(sid, msg),
10
)
subs['position_ned'] = pos_ned_sub
setattr(self, subs_attr, subs) # 明確保存更新後的字典
except Exception as e:
pass
def setup_drone(self, sys_id): def setup_drone(self, sys_id):
# sys_id 格式: sys11, sys12, ... # sys_id 格式: sys11, sys12, ...
@@ -568,6 +654,12 @@ class DroneMonitor(Node):
f'{base_topic}/vfr_hud', f'{base_topic}/vfr_hud',
lambda msg, sid=sys_id: self.hud_callback(sid, msg), lambda msg, sid=sys_id: self.hud_callback(sid, msg),
10 10
),
'position_ned': self.create_subscription(
Odometry,
f'{base_topic}/position_ned',
lambda msg, sid=sys_id: self.position_ned_callback(sid, msg),
10
) )
} }
@@ -619,17 +711,19 @@ class DroneMonitor(Node):
print(f"\n📢 [SET_MODE] {drone_id} → {mode_name} (custom_mode={custom_mode})") print(f"\n📢 [SET_MODE] {drone_id} → {mode_name} (custom_mode={custom_mode})")
if not self.command_long_client: # 獲取或創建該 drone 專用的 client(避免多機並行時的競態條件)
print(f"❌ [SET_MODE] CommandLongClient 未初始化") client = self.get_or_create_client(drone_id)
if not client:
print(f"❌ [SET_MODE] CommandLongClient 無法初始化")
return False return False
# 直接調用 async 方法,無需線程池(避免嵌套執行器衝突) # 直接調用 async 方法,無需線程池(避免嵌套執行器衝突)
result = await self.command_long_client.change_mode_async( result = await client.change_mode_async(
target_sysid=sysid, target_sysid=sysid,
custom_mode=float(custom_mode), custom_mode=float(custom_mode),
target_compid=0, target_compid=0,
base_mode=1.0, base_mode=1.0,
timeout_sec=2.0, timeout_sec=5.0, # 增加超時時間以提高多機操作的可靠性
) )
if result and result.success: if result and result.success:
@@ -656,16 +750,18 @@ class DroneMonitor(Node):
action_name = "解鎖" if arm else "上鎖" action_name = "解鎖" if arm else "上鎖"
print(f"\n📢 [ARM] {drone_id} → {action_name}") print(f"\n📢 [ARM] {drone_id} → {action_name}")
if not self.command_long_client: # 獲取或創建該 drone 專用的 client(避免多機並行時的競態條件)
print(f"❌ [ARM] CommandLongClient 未初始化") client = self.get_or_create_client(drone_id)
if not client:
print(f"❌ [ARM] CommandLongClient 無法初始化")
return False return False
# 直接調用 async 方法,無需線程池(避免嵌套執行器衝突) # 直接調用 async 方法,無需線程池(避免嵌套執行器衝突)
result = await self.command_long_client.arm_disarm_async( result = await client.arm_disarm_async(
target_sysid=sysid, target_sysid=sysid,
arm=arm, arm=arm,
target_compid=0, target_compid=0,
timeout_sec=2.0, timeout_sec=5.0, # 增加超時時間以提高多機操作的可靠性
) )
if result and result.success: if result and result.success:
@@ -691,16 +787,18 @@ class DroneMonitor(Node):
print(f"\n📢 [TAKEOFF] {drone_id} → 起飛 (高度={altitude}m)") print(f"\n📢 [TAKEOFF] {drone_id} → 起飛 (高度={altitude}m)")
if not self.command_long_client: # 獲取或創建該 drone 專用的 client(避免多機並行時的競態條件)
print(f"❌ [TAKEOFF] CommandLongClient 未初始化") client = self.get_or_create_client(drone_id)
if not client:
print(f"❌ [TAKEOFF] CommandLongClient 無法初始化")
return False return False
# 直接調用 async 方法,無需線程池(避免嵌套執行器衝突) # 直接調用 async 方法,無需線程池(避免嵌套執行器衝突)
result = await self.command_long_client.takeoff_async( result = await client.takeoff_async(
target_sysid=sysid, target_sysid=sysid,
altitude_m=float(altitude), altitude_m=float(altitude),
target_compid=0, target_compid=0,
timeout_sec=2.0, timeout_sec=5.0, # 增加超時時間以提高多機操作的可靠性
) )
if result and result.success: if result and result.success:
@@ -805,6 +903,7 @@ class DroneMonitor(Node):
sys_num = sys_id.replace('sys', '') sys_num = sys_id.replace('sys', '')
actual_drone_id = f's{assigned_socket_id}_{sys_num}' actual_drone_id = f's{assigned_socket_id}_{sys_num}'
self.sys_to_actual_id[sys_id] = actual_drone_id self.sys_to_actual_id[sys_id] = actual_drone_id
print(f"[DEBUG] summary_callback: 已創建映射 {sys_id} -> {actual_drone_id} (使用 sys_num)")
# 先發送連接類型資訊 # 先發送連接類型資訊
self.signals.update_signal.emit('connection_type', actual_drone_id, { self.signals.update_signal.emit('connection_type', actual_drone_id, {
@@ -885,6 +984,45 @@ class DroneMonitor(Node):
'climb': msg.climb 'climb': msg.climb
} }
def position_ned_callback(self, sys_id, msg):
"""處理 position_ned topic (nav_msgs/msg/Odometry)
NED 座標系中的位置(在 msg.pose.pose.position):
- x: 北向位移 (m)
- y: 東向位移 (m)
- z: 向下位移 (m) - 負值表示向下,轉換為高度需要 * (-1)
"""
try:
# 使用映射獲取實際的 drone_id
actual_drone_id = self.sys_to_actual_id.get(sys_id, None)
# 如果還沒有從 summary 獲取到映射,則不處理
if actual_drone_id is None:
return
# 從 Odometry 消息中提取位置數據 (msg.pose.pose.position)
# Odometry 結構:header, child_frame_id, pose(包含PoseWithCovariance), twist
x = msg.pose.pose.position.y # NED 座標系中交換 x/y(與 local_pose 對齐)
y = msg.pose.pose.position.x
z = -msg.pose.pose.position.z # 將向下的 NED z 轉換為向上的高度(z 為負表示向下)
# 儲存高度信息
self.latest_data[(actual_drone_id, 'altitude')] = {
'altitude': z
}
# 儲存本地位置信息
self.latest_data[(actual_drone_id, 'local_pose')] = {
'x': x,
'y': y,
'z': z
}
# 發送信號給 GUI 更新高度顯示
self.signals.update_signal.emit('altitude', actual_drone_id, {
'altitude': z
})
except Exception as e:
pass
def loss_rate_callback(self, drone_id, msg): def loss_rate_callback(self, drone_id, msg):
self.latest_data[(drone_id, 'loss_rate')] = { self.latest_data[(drone_id, 'loss_rate')] = {
'loss_rate': msg.data 'loss_rate': msg.data
+409 -274
View File
@@ -25,7 +25,7 @@ from overview_table import OverviewTable
# 導入任務規劃器、執行器、發送器、群組 # 導入任務規劃器、執行器、發送器、群組
# ================================================================================ # ================================================================================
from mission_planner import FormationPlanner, MissionType from mission_planner import FormationPlanner, MissionType
from command_sender import MavlinkSender from command_sender import MavlinkSender, Ros2CommandSender
from mission_executor import MissionExecutor, MissionState from mission_executor import MissionExecutor, MissionState
from mission_group import ( from mission_group import (
MissionGroup, GroupPanel, DroneAssignDialog, GROUP_COLORS MissionGroup, GroupPanel, DroneAssignDialog, GROUP_COLORS
@@ -33,7 +33,7 @@ from mission_group import (
# ================================================================================ # ================================================================================
class ControlStationUI(QMainWindow): class ControlStationUI(QMainWindow):
VERSION = '2.0.6' VERSION = '2.0.8'
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -52,9 +52,8 @@ class ControlStationUI(QMainWindow):
self.executor = rclpy.executors.SingleThreadedExecutor() self.executor = rclpy.executors.SingleThreadedExecutor()
self.executor.add_node(self.monitor) self.executor.add_node(self.monitor)
# 添加 CommandLongClient 到執行器(這樣它的回調才能被處理) # 將執行器註冊到 DroneMonitor,以便動態創建的 CommandLongClient 能被添加
if self.monitor.command_long_client: self.monitor.executor = self.executor
self.executor.add_node(self.monitor.command_long_client)
# 定时处理ROS事件 # 定时处理ROS事件
self.timer = QTimer() self.timer = QTimer()
@@ -123,7 +122,8 @@ class ControlStationUI(QMainWindow):
# ================================================================================ # ================================================================================
# 初始化指令發送器(所有群組共用) # 初始化指令發送器(所有群組共用)
# ================================================================================ # ================================================================================
self.command_sender = MavlinkSender("udpout:127.0.0.1:14550") # 驗證階段寫死 # 走 ROS2 /fc_network/vehicle/pos_global_int service;per-drone client + asyncio polling
self.command_sender = Ros2CommandSender(monitor=self.monitor)
# ================================================================================ # ================================================================================
# ================================================================================ # ================================================================================
@@ -450,11 +450,17 @@ class ControlStationUI(QMainWindow):
loop.create_task(self.handle_service_response(future, f"起飛 {drone_id}")) loop.create_task(self.handle_service_response(future, f"起飛 {drone_id}"))
def handle_setpoint_selected(self): def handle_setpoint_selected(self):
"""發送位置命令到 active group 的所有選中無人機"""
group = self._get_active_group()
if not group:
self.statusBar().showMessage("⚠ 請先建立任務群組", 3000)
return
try: try:
x = float(self.x_input.text() or '0') x = float(self.x_input.text() or '0')
y = float(self.y_input.text() or '0') y = float(self.y_input.text() or '0')
z = float(self.z_input.text() or '0') z = float(self.z_input.text() or '0')
for drone_id in self.monitor.selected_drones: for drone_id in group.selected_drone_ids:
if self.monitor.send_setpoint(drone_id, x, y, z): if self.monitor.send_setpoint(drone_id, x, y, z):
self.statusBar().showMessage(f"發送位置命令到 {drone_id}: ({x}, {y}, {z})", 3000) self.statusBar().showMessage(f"發送位置命令到 {drone_id}: ({x}, {y}, {z})", 3000)
else: else:
@@ -507,8 +513,18 @@ class ControlStationUI(QMainWindow):
self.statusBar().showMessage(f"{action} 錯誤: {str(e)}", 3000) self.statusBar().showMessage(f"{action} 錯誤: {str(e)}", 3000)
def handle_arm_selected(self): def handle_arm_selected(self):
"""解鎖 active group 的所有選中無人機"""
group = self._get_active_group()
if not group:
self.statusBar().showMessage("⚠ 請先建立任務群組", 3000)
return
selected = list(group.selected_drone_ids)
if not selected:
self.statusBar().showMessage("⚠ 尚未選擇任何無人機", 3000)
return
print(f"\n📢 [GUI] handle_arm_selected 被調用") print(f"\n📢 [GUI] handle_arm_selected 被調用")
selected = list(self.monitor.selected_drones)
print(f" 已選擇的無人機: {selected}") print(f" 已選擇的無人機: {selected}")
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
for drone_id in selected: for drone_id in selected:
@@ -523,8 +539,19 @@ class ControlStationUI(QMainWindow):
print(f" handle_arm_selected 完成\n") print(f" handle_arm_selected 完成\n")
def handle_takeoff_selected(self): def handle_takeoff_selected(self):
"""起飛 active group 的所有選中無人機"""
group = self._get_active_group()
if not group:
self.statusBar().showMessage("⚠ 請先建立任務群組", 3000)
return
selected = list(group.selected_drone_ids)
if not selected:
self.statusBar().showMessage("⚠ 尚未選擇任何無人機", 3000)
return
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
for drone_id in self.monitor.selected_drones: for drone_id in selected:
future = self.monitor.takeoff_drone(drone_id, 10.0) future = self.monitor.takeoff_drone(drone_id, 10.0)
loop.create_task(self.handle_service_response(future, f"批次起飛 {drone_id}")) loop.create_task(self.handle_service_response(future, f"批次起飛 {drone_id}"))
@@ -584,22 +611,34 @@ class ControlStationUI(QMainWindow):
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
idx = self.group_tab_widget.addTab(scroll, f"Group {gid}") idx = self.group_tab_widget.addTab(scroll, f"Group {gid}")
self.group_tab_widget.tabBar().setTabTextColor(idx, QColor(color)) self.group_tab_widget.tabBar().setTabTextColor(idx, QColor(color))
# 切換到新 group 的 tab
self.group_tab_widget.setCurrentIndex(idx) self.group_tab_widget.setCurrentIndex(idx)
self.active_group_id = gid
self.statusBar().showMessage(f"已新增 Group {gid}", 2000) self.statusBar().showMessage(f"已新增 Group {gid}", 2000)
# 更新刪除按鈕的啟用/禁用狀態
self._update_delete_buttons_state()
def _on_group_tab_changed(self, index): def _on_group_tab_changed(self, index):
"""Tab 切換時更新 active group 並同步地圖模式""" """切換 group tab — 只需切換 active group 並刷新 UI"""
if index < 0: if index < 0:
self.active_group_id = None self.active_group_id = None
return return
# tab 標題是 "Group X"
tab_text = self.group_tab_widget.tabText(index) tab_text = self.group_tab_widget.tabText(index)
gid = tab_text.replace("Group ", "") gid = tab_text.replace("Group ", "")
if gid in self.mission_groups: if gid not in self.mission_groups:
self.active_group_id = gid return
group = self.mission_groups[gid]
self.drone_map.set_mission_mode(group.mission_type) self.active_group_id = gid
group = self.mission_groups[gid]
# 同步地圖的任務模式
self.drone_map.set_mission_mode(group.mission_type)
# 統一刷新所有 UI
self.refresh_selection_ui()
def _get_active_group(self): def _get_active_group(self):
"""取得當前 active 的 MissionGroup""" """取得當前 active 的 MissionGroup"""
@@ -607,53 +646,140 @@ class ControlStationUI(QMainWindow):
return self.mission_groups[self.active_group_id] return self.mission_groups[self.active_group_id]
return None return None
def _get_other_assigned(self, exclude_gid): def refresh_selection_ui(self):
"""取得其他群組已佔用的無人機 {drone_id: group_id}""" """統一刷新所有 UI 元素:左側 drone 勾選、左側 socket 勾選、右側 group panel"""
assigned = {} group = self._get_active_group()
for gid, group in self.mission_groups.items(): selected = group.selected_drone_ids if group else set()
if gid == exclude_gid: current_group_id = group.group_id if group else None
continue
for did in group.drone_ids: # 左側 drone checkbox
assigned[did] = gid for drone_id, panel in self.drones.items():
return assigned checkbox = panel.get_checkbox()
if checkbox:
blocked = (
current_group_id is not None and
self._is_drone_selected_by_other_group(drone_id, current_group_id)
)
checkbox.blockSignals(True)
checkbox.setStyleSheet(self._get_drone_checkbox_style(blocked))
checkbox.setChecked(drone_id in selected)
checkbox.setEnabled(not blocked or drone_id in selected)
checkbox.blockSignals(False)
# 左側 socket checkbox
for socket_id in self.socket_groups.keys():
self.refresh_socket_checkbox(socket_id, selected, current_group_id)
# 右側 group panel
if group:
panel = self.group_panels.get(group.group_id)
if panel:
panel.update_drone_list()
panel.update_status()
def _get_drone_checkbox_style(self, blocked=False):
"""回傳 drone checkbox 樣式;被其他 group 佔用時顯示紅色填滿。"""
blocked_style = """
QCheckBox::indicator:disabled {
background-color: #D32F2F;
border: 2px solid #A32020;
}
""" if blocked else ""
return f"""
QCheckBox {{
color: #DDD;
}}
QCheckBox::indicator {{
width: 16px;
height: 16px;
border: 2px solid #888888;
border-radius: 3px;
background: transparent;
}}
QCheckBox::indicator:checked {{
background-color: #7FFFD4;
border: 2px solid #888888;
}}
{blocked_style}
"""
def refresh_socket_checkbox(self, socket_id, selected_ids, current_group_id=None):
"""根據 selected_ids 推導並更新 socket 的勾選狀態"""
drone_ids = [did for did in self.drones.keys() if self.get_socket_id(did) == socket_id]
if not drone_ids:
return
socket_widget = self.socket_groups.get(socket_id)
if not socket_widget:
return
checkbox = socket_widget.findChild(QCheckBox, f"socket_{socket_id}_checkbox")
if not checkbox:
return
effective_drone_ids = [
did for did in drone_ids
if current_group_id is None
or did in selected_ids
or not self._is_drone_selected_by_other_group(did, current_group_id)
]
checked_count = sum(1 for did in effective_drone_ids if did in selected_ids)
checkbox.blockSignals(True)
if checked_count == 0:
checkbox.setCheckState(Qt.CheckState.Unchecked)
elif checked_count == len(effective_drone_ids):
checkbox.setCheckState(Qt.CheckState.Checked)
else:
checkbox.setCheckState(Qt.CheckState.PartiallyChecked)
checkbox.setEnabled(len(effective_drone_ids) > 0)
checkbox.blockSignals(False)
# 【已遷移至 refresh_socket_checkbox()】舊的 update_group_checkbox_state() 已廢棄
def _handle_assign_drones(self, group_id): def _handle_assign_drones(self, group_id):
"""開啟無人機分配對話框(已勾選的 checkbox 會預先帶入)""" """開啟無人機分配對話框 — 過濾被占用的drone"""
group = self.mission_groups.get(group_id) group = self.mission_groups.get(group_id)
if not group: if not group:
return return
all_ids = list(self.drones.keys()) all_ids = list(self.drones.keys())
other_assigned = self._get_other_assigned(group_id)
# 將目前 checkbox 已勾選的無人機(且未被其他群組佔用)合併進 pre-selected # 預先選中:目前 group 的 selected_drone_ids
currently_checked = self.get_selected_drones() pre_selected = set(group.selected_drone_ids)
pre_selected = set(group.drone_ids)
for did in currently_checked:
if did not in other_assigned:
pre_selected.add(did)
dialog = DroneAssignDialog(self, all_ids, pre_selected, other_assigned) dialog = DroneAssignDialog(self, all_ids, pre_selected, {})
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
group.drone_ids = dialog.get_selected() requested = dialog.get_selected()
allowed = set()
blocked = set()
for did in requested:
if self._is_drone_selected_by_other_group(did, group_id):
blocked.add(did)
else:
allowed.add(did)
group.selected_drone_ids = allowed
# 只有當操作目標組是 active 組時,才更新 UI
if group_id == self.active_group_id:
self.refresh_selection_ui()
panel = self.group_panels.get(group_id) panel = self.group_panels.get(group_id)
if panel: if panel:
panel.update_drone_list() panel.update_drone_list()
panel.update_status() panel.update_status()
# 同步更新左側面板的 checkbox 狀態 if blocked:
self.monitor.selected_drones = group.drone_ids.copy() self.statusBar().showMessage(
for drone_id in all_ids: f"⚠ Group {group_id}: {len(blocked)} 台已被其他 group 勾選,未加入", 4000
if drone_id in self.drones: )
checkbox = self.drones[drone_id].get_checkbox() else:
if checkbox: self.statusBar().showMessage(
checkbox.blockSignals(True) f"Group {group_id}: 已分配 {len(group.selected_drone_ids)} 台無人機", 3000
checkbox.setChecked(drone_id in group.drone_ids) )
checkbox.blockSignals(False)
# 更新 socket 群組的 checkbox 狀態
self.update_group_checkbox_state(self.get_socket_id(drone_id))
self.statusBar().showMessage(
f"Group {group_id}: 已分配 {len(group.drone_ids)} 台無人機", 3000)
def _handle_mission_type_changed(self, group_id, mission_type): def _handle_mission_type_changed(self, group_id, mission_type):
"""群組任務類型切換""" """群組任務類型切換"""
@@ -669,10 +795,12 @@ class ControlStationUI(QMainWindow):
executor = MissionExecutor( executor = MissionExecutor(
sender=self.command_sender, sender=self.command_sender,
drone_gps=self.monitor.drone_gps, drone_gps=self.monitor.drone_gps,
monitor=self.monitor,
arrival_radius=2.0, arrival_radius=2.0,
send_rate_hz=2.0 tick_rate_hz=2.0,
) )
executor.drone_waypoint_reached.connect(self.on_drone_waypoint_reached) executor.drone_waypoint_reached.connect(self.on_drone_waypoint_reached)
executor.task_status_changed.connect(self._on_task_status_changed)
executor.mission_completed.connect( executor.mission_completed.connect(
lambda gid=group.group_id: self._on_group_mission_completed(gid)) lambda gid=group.group_id: self._on_group_mission_completed(gid))
group.executor = executor group.executor = executor
@@ -733,19 +861,19 @@ class ControlStationUI(QMainWindow):
print(f"❌ 找不到群組: {group_id}", flush=True) print(f"❌ 找不到群組: {group_id}", flush=True)
return return
if not group.drone_ids: if not group.selected_drone_ids:
print(f"⚠️ 群組中沒有無人機", flush=True) print(f"⚠️ 群組中沒有無人機", flush=True)
self.statusBar().showMessage(f"群組 {group_id} 中沒有無人機", 3000) self.statusBar().showMessage(f"群組 {group_id} 中沒有無人機", 3000)
return return
print(f" 準備為 {len(group.drone_ids)} 台無人機切換模式...", flush=True) print(f" 準備為 {len(group.selected_drone_ids)} 台無人機切換模式...", flush=True)
self.statusBar().showMessage(f"正在切換模式...", 1000) self.statusBar().showMessage(f"正在切換模式...", 1000)
# 使用 asyncio 執行(通過事件循環) # 使用 asyncio 執行(通過事件循環)
async def do_mode_changes_async(): async def do_mode_changes_async():
print(f"\n 【異步任務】開始執行模式切換", flush=True) print(f"\n 【異步任務】開始執行模式切換", flush=True)
for drone_id in group.drone_ids: for drone_id in group.selected_drone_ids:
print(f"\n 【切換】{drone_id} → {mode}", flush=True) print(f"\n 【切換】{drone_id} → {mode}", flush=True)
try: try:
result = await self.monitor.set_mode(drone_id, mode) result = await self.monitor.set_mode(drone_id, mode)
@@ -784,11 +912,11 @@ class ControlStationUI(QMainWindow):
if not group: if not group:
print(f" ⚠️ 群組不存在,返回\n") print(f" ⚠️ 群組不存在,返回\n")
return return
print(f" 群組內無人機: {group.drone_ids}") print(f" 群組內無人機: {group.selected_drone_ids}")
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
print(f" 事件循環: {loop}") print(f" 事件循環: {loop}")
for drone_id in group.drone_ids: for drone_id in group.selected_drone_ids:
print(f"\n ┌─ 處理無人機: {drone_id}") print(f"\n ┌─ 處理無人機: {drone_id}")
print(f" ├─ 準備調用 arm_drone(drone_id={drone_id}, arm=True)") print(f" ├─ 準備調用 arm_drone(drone_id={drone_id}, arm=True)")
coro = self.monitor.arm_drone(drone_id, True) coro = self.monitor.arm_drone(drone_id, True)
@@ -812,7 +940,7 @@ class ControlStationUI(QMainWindow):
if not group: if not group:
return return
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
for drone_id in group.drone_ids: for drone_id in group.selected_drone_ids:
future = self.monitor.takeoff_drone(drone_id, altitude) future = self.monitor.takeoff_drone(drone_id, altitude)
loop.create_task(self.handle_service_response(future, f"起飛 {drone_id} ({altitude}m)")) loop.create_task(self.handle_service_response(future, f"起飛 {drone_id} ({altitude}m)"))
@@ -824,107 +952,106 @@ class ControlStationUI(QMainWindow):
f"請在地圖上框選要分配到 Group {group_id} 的無人機", 5000) f"請在地圖上框選要分配到 Group {group_id} 的無人機", 5000)
def _handle_drone_box_selected(self, drone_ids_json): def _handle_drone_box_selected(self, drone_ids_json):
"""地圖框選完成 — 直接分配到指定群組"""
group_id = self._pending_box_assign group_id = self._pending_box_assign
self._pending_box_assign = None self._pending_box_assign = None
if not group_id: if not group_id:
return return
group = self.mission_groups.get(group_id) group = self.mission_groups.get(group_id)
if not group: if not group:
return return
drone_ids = json.loads(drone_ids_json) drone_ids = json.loads(drone_ids_json)
other = self._get_other_assigned(group_id)
valid = {did for did in drone_ids if did not in other} allowed = set()
group.drone_ids = valid blocked = set()
for did in drone_ids:
if self._is_drone_selected_by_other_group(did, group_id):
blocked.add(did)
else:
allowed.add(did)
group.selected_drone_ids = allowed
if group_id == self.active_group_id:
self.refresh_selection_ui()
panel = self.group_panels.get(group_id) panel = self.group_panels.get(group_id)
if panel: if panel:
panel.update_drone_list() panel.update_drone_list()
panel.update_status() panel.update_status()
# 同步更新左側面板的 checkbox 狀態 if blocked:
self.monitor.selected_drones = group.drone_ids.copy() self.statusBar().showMessage(
for drone_id in self.drones.keys(): f"⚠ Group {group_id}: 部分 drone 已被其他 group 勾選,僅加入 {len(allowed)} 台", 4000
checkbox = self.drones[drone_id].get_checkbox() )
if checkbox: else:
checkbox.blockSignals(True) self.statusBar().showMessage(
checkbox.setChecked(drone_id in group.drone_ids) f"Group {group_id}: 框選分配 {len(allowed)} 台無人機", 3000
checkbox.blockSignals(False) )
self.update_group_checkbox_state(self.get_socket_id(drone_id))
self.statusBar().showMessage(
f"Group {group_id}: 框選分配 {len(valid)} 台無人機", 3000)
def _handle_select_all_for_group(self, group_id): def _handle_select_all_for_group(self, group_id):
"""全選/取消全選 - Toggle 邏輯""" """全選/取消全選 — Toggle 邏輯"""
group = self.mission_groups.get(group_id) group = self.mission_groups.get(group_id)
if not group: if not group:
return return
other = self._get_other_assigned(group_id) selectable_ids = self._get_selectable_drones_for_group(group_id) | set(group.selected_drone_ids)
available = {did for did in self.drones.keys() if did not in other}
# Toggle 邏輯:如果已全選,則清空;否則全選 # Toggle 邏輯:如果已全選,則清空;否則全選
if group.drone_ids == available: if group.selected_drone_ids == selectable_ids:
# 已全選 → 清空 # 已全選 → 清空
group.drone_ids = set() group.selected_drone_ids.clear()
self.monitor.selected_drones.clear()
msg_status = "已取消全選" msg_status = "已取消全選"
else: else:
# 未全選 → 全選 # 未全選 → 全選
group.drone_ids = available group.selected_drone_ids = set(selectable_ids)
self.monitor.selected_drones = group.drone_ids.copy() msg_status = f"全選可用無人機 {len(selectable_ids)} 台"
msg_status = f"全選分配 {len(available)} 台無人機"
# 只有當操作目標組是 active 組時,才更新 UI
if group_id == self.active_group_id:
self.refresh_selection_ui()
panel = self.group_panels.get(group_id) panel = self.group_panels.get(group_id)
if panel: if panel:
panel.update_drone_list() panel.update_drone_list()
panel.update_status() panel.update_status()
# 更新按鈕文本
panel.set_all_select_state(group.drone_ids == available)
# 同步更新左側面板的 checkbox 狀態
for drone_id in self.drones.keys():
checkbox = self.drones[drone_id].get_checkbox()
if checkbox:
checkbox.blockSignals(True)
checkbox.setChecked(drone_id in group.drone_ids)
checkbox.blockSignals(False)
self.update_group_checkbox_state(self.get_socket_id(drone_id))
self.statusBar().showMessage( self.statusBar().showMessage(
f"Group {group_id}: {msg_status}", 3000) f"Group {group_id}: {msg_status}", 3000)
def _handle_clear_group(self, group_id): def _handle_clear_group(self, group_id):
"""清除群組的無人機分配""" """清除群組的無人機選擇"""
group = self.mission_groups.get(group_id) group = self.mission_groups.get(group_id)
if not group: if not group:
return return
group.drone_ids = set()
group.selected_drone_ids.clear()
group.planned_waypoints = None group.planned_waypoints = None
if group.executor: if group.executor:
group.executor.stop() group.executor.stop()
self.drone_map.clear_mission_plan_for_group(group_id) self.drone_map.clear_mission_plan_for_group(group_id)
# 只有當操作目標組是 active 組時,才更新 UI
if group_id == self.active_group_id:
self.refresh_selection_ui()
panel = self.group_panels.get(group_id) panel = self.group_panels.get(group_id)
if panel: if panel:
panel.update_drone_list() panel.update_drone_list()
panel.update_status() panel.update_status()
panel.clear_mission_info() panel.clear_mission_info()
# 同步更新左側面板的 checkbox 狀態(全部取消勾選)
self.monitor.selected_drones.clear()
for drone_id in self.drones.keys():
checkbox = self.drones[drone_id].get_checkbox()
if checkbox:
checkbox.blockSignals(True)
checkbox.setChecked(False)
checkbox.blockSignals(False)
self.update_group_checkbox_state(self.get_socket_id(drone_id))
self.statusBar().showMessage( self.statusBar().showMessage(
f"Group {group_id}: 已清除分組", 3000) f"Group {group_id}: 已清除分組", 3000)
def _handle_delete_group(self, group_id): def _handle_delete_group(self, group_id):
"""刪除一個任務群組""" """刪除一個任務群組"""
# 檢查是否只有一個群組,如果是就禁止刪除
if len(self.mission_groups) <= 1:
self.statusBar().showMessage("⚠️ 至少需要保留一個群組!", 3000)
return
if group_id not in self.mission_groups: if group_id not in self.mission_groups:
self.statusBar().showMessage(f"Group {group_id} 不存在", 3000) self.statusBar().showMessage(f"Group {group_id} 不存在", 3000)
return return
@@ -948,20 +1075,26 @@ class ControlStationUI(QMainWindow):
if group_id in self.group_panels: if group_id in self.group_panels:
del self.group_panels[group_id] del self.group_panels[group_id]
# 更新 active group # 更新 active group — 讓 currentChanged signal 自動處理
if self.active_group_id == group_id: if self.active_group_id == group_id:
self.active_group_id = None
if self.group_tab_widget.count() > 0: if self.group_tab_widget.count() > 0:
self.group_tab_widget.setCurrentIndex(0) self.group_tab_widget.setCurrentIndex(0)
# 更新 active_group_id 為當前 tab 的群組 # _on_group_tab_changed 會自動設定新的 active group
for gid, panel in self.group_panels.items():
if panel == self.group_tab_widget.currentWidget().widget():
self.active_group_id = gid
break
else:
self.active_group_id = None
self.statusBar().showMessage(f"已刪除 Group {group_id}", 3000) self.statusBar().showMessage(f"已刪除 Group {group_id}", 3000)
# 更新刪除按鈕的啟用/禁用狀態
self._update_delete_buttons_state()
def _update_delete_buttons_state(self):
"""根據群組數量,更新所有群組的刪除按鈕啟用/禁用狀態"""
# 如果只有一個群組,禁用該群組的刪除按鈕
# 如果有多個群組,啟用所有刪除按鈕
should_enable = len(self.mission_groups) > 1
for gid, panel in self.group_panels.items():
panel.set_delete_enabled(should_enable)
def _on_group_mission_completed(self, group_id): def _on_group_mission_completed(self, group_id):
"""群組任務完成回呼""" """群組任務完成回呼"""
panel = self.group_panels.get(group_id) panel = self.group_panels.get(group_id)
@@ -1002,173 +1135,143 @@ class ControlStationUI(QMainWindow):
# ================================================================================ # ================================================================================
def handle_group_selection(self, socket_id, state): def handle_group_selection(self, socket_id, state):
"""處理 socket 群組 checkbox 的勾選/取消勾選 """Socket 群組勾選 — 更新該 socket 下所有 drone 的選擇狀態"""
group = self._get_active_group()
if not group:
return
這個方法在用戶點擊 socket 群組的 checkbox 時被調用。 drone_ids = [did for did in self.drones.keys() if self.get_socket_id(did) == socket_id]
需要同時更新: selectable = []
1. 該 socket 下所有無人機的 checkbox blocked = []
2. self.monitor.selected_drones(用於控制面板同步) for did in drone_ids:
3. 右侧活躍群組的無人機分配(新增) if self._is_drone_selected_by_other_group(did, group.group_id):
blocked.append(did)
else:
selectable.append(did)
參數: if not selectable:
socket_id: socket ID (str) if blocked:
state: Qt.CheckState 的整數值 (0=Unchecked, 1=PartiallyChecked, 2=Checked) self.statusBar().showMessage(
f"⚠ Socket {socket_id} 的 drone 已被其他 group 勾選,無法操作", 4000
)
self.refresh_selection_ui()
return
all_selectable_selected = all(did in group.selected_drone_ids for did in selectable)
if all_selectable_selected:
group.selected_drone_ids.difference_update(selectable)
else:
group.selected_drone_ids.update(selectable)
if blocked:
self.statusBar().showMessage(
f"⚠ 以下 drone 已被其他 group 勾選,略過: {', '.join(blocked)}", 4000
)
self.refresh_selection_ui()
def _is_drone_selected_by_other_group(self, drone_id, current_group_id):
""" """
print(f"\n📢 [GUI] handle_group_selection 被調用", flush=True) 檢查該 drone 是否已被其他 group 勾選
print(f" socket_id: {socket_id}, state: {state}", flush=True)
print(f" state 類型: {type(state)}", flush=True)
# 獲取該 socket 下所有無人機 Args:
group_drones = [did for did in self.drones.keys() if self.get_socket_id(did) == socket_id] drone_id: 無人機 ID
print(f" 該 socket 下的無人機: {group_drones}", flush=True) current_group_id: 當前 group 的 ID
# 判斷是否勾選(只有 state == 2 時才是 Checked) Returns:
is_checked = (state == 2) # Qt.CheckState.Checked.value == 2 True 如果 drone 已被其他 group 選擇,否則 False
print(f" is_checked: {is_checked}", flush=True) """
for gid, group in self.mission_groups.items():
# 跳過當前 group
if gid == current_group_id:
continue
# 檢查該 drone 是否在其他 group 中被勾選
if drone_id in group.selected_drone_ids:
return True
return False
# 更新該 socket 下所有無人機的 checkbox 狀態 def _get_selectable_drones_for_group(self, group_id):
for drone_id in group_drones: """
checkbox = self.drones[drone_id].get_checkbox() 取得指定 group 可以選擇的 drone 清單(不被其他 group 佔用的)
if checkbox:
print(f" └─ 更新 {drone_id}: setChecked({is_checked})", flush=True)
checkbox.blockSignals(True)
checkbox.setChecked(is_checked)
checkbox.blockSignals(False)
# 同時更新 monitor.selected_drones 以同步控制面板 Args:
if is_checked: group_id: group 的 ID
self.monitor.selected_drones.add(drone_id)
else:
self.monitor.selected_drones.discard(drone_id)
# 👇 新增:同步更新右侧活躍群組的無人機分配 Returns:
if self.active_group_id: set: 可選擇的 drone ID 集合
group = self.mission_groups.get(self.active_group_id) """
panel = self.group_panels.get(self.active_group_id) selectable = set()
if group and panel: for did in self.drones.keys():
print(f" ├─ 同步右侧群組 {self.active_group_id}", flush=True) if not self._is_drone_selected_by_other_group(did, group_id):
if is_checked: selectable.add(did)
# 勾選時:將該 socket 下的無人機添加到活躍群組 return selectable
for drone_id in group_drones:
group.drone_ids.add(drone_id)
print(f" │ └─ 添加到群組: {group_drones}", flush=True)
else:
# 取消勾選時:從活躍群組移除該 socket 下的無人機
for drone_id in group_drones:
group.drone_ids.discard(drone_id)
print(f" │ └─ 從群組移除: {group_drones}", flush=True)
# 更新右側群組面板的顯示
panel.update_drone_list()
panel.update_status()
print(f" │ └─ 已更新右侧群組面板", flush=True)
print(f" 最終 selected_drones: {self.monitor.selected_drones}", flush=True)
print(f"✓ handle_group_selection 完成\n", flush=True)
def handle_drone_selection(self, drone_id, state): def handle_drone_selection(self, drone_id, state):
is_checked = state == Qt.CheckState.Checked.value """單台 drone 勾選 — 只修改 active group 的 selected_drone_ids"""
group = self._get_active_group()
if not group:
return
is_checked = (state == Qt.CheckState.Checked.value)
if is_checked: if is_checked:
self.monitor.selected_drones.add(drone_id) if self._is_drone_selected_by_other_group(drone_id, group.group_id):
self.statusBar().showMessage(
f"⚠ {drone_id} 已被其他 group 勾選,無法加入 Group {group.group_id}", 3000
)
self.refresh_selection_ui()
return
group.selected_drone_ids.add(drone_id)
else: else:
self.monitor.selected_drones.discard(drone_id) group.selected_drone_ids.discard(drone_id)
self.update_group_checkbox_state(self.get_socket_id(drone_id))
# 同步更新任務群組的無人機分配狀態 self.refresh_selection_ui()
# 遍歷所有任務群組,更新已分配的無人機列表顯示
if not is_checked:
# 取消勾選時:從所有包含該無人機的群組中移除
for group_id, group in self.mission_groups.items():
if drone_id in group.drone_ids:
group.drone_ids.discard(drone_id)
panel = self.group_panels.get(group_id)
if panel:
panel.update_drone_list()
panel.update_status()
# 更新全選按鈕狀態
other = self._get_other_assigned(group_id)
available = {did for did in self.drones.keys() if did not in other}
panel.set_all_select_state(group.drone_ids == available)
else:
# 勾選時:檢查該無人機是否已分配給其他群組,若未分配則添加到當前活躍群組
is_already_assigned = any(
drone_id in group.drone_ids
for group in self.mission_groups.values()
)
if not is_already_assigned and self.active_group_id:
# 無人機未被分配給任何群組,可以添加到當前活躍群組
group = self.mission_groups.get(self.active_group_id)
panel = self.group_panels.get(self.active_group_id)
if group and panel:
group.drone_ids.add(drone_id)
panel.update_drone_list()
panel.update_status()
# 更新全選按鈕狀態
other = self._get_other_assigned(self.active_group_id)
available = {did for did in self.drones.keys() if did not in other}
panel.set_all_select_state(group.drone_ids == available)
def update_group_checkbox_state(self, socket_id):
group_drones = [did for did in self.drones.keys() if self.get_socket_id(did) == socket_id]
if not group_drones: return
checked_count = sum(1 for did in group_drones if self.drones[did].is_checked())
if socket_id in self.socket_groups:
group_checkbox = self.socket_groups[socket_id].findChild(QCheckBox, f"socket_{socket_id}_checkbox")
if group_checkbox:
group_checkbox.blockSignals(True)
if checked_count == 0: group_checkbox.setCheckState(Qt.CheckState.Unchecked)
elif checked_count == len(group_drones): group_checkbox.setCheckState(Qt.CheckState.Checked)
else: group_checkbox.setCheckState(Qt.CheckState.PartiallyChecked)
group_checkbox.blockSignals(False)
def handle_select_all(self):
all_selected = all(self.drones[did].is_checked() for did in self.drones)
new_state = not all_selected
for drone_id in self.drones:
self.drones[drone_id].set_checked(new_state)
for socket_id in self.socket_groups:
group_checkbox = self.socket_groups[socket_id].findChild(QCheckBox, f"socket_{socket_id}_checkbox")
if group_checkbox:
group_checkbox.blockSignals(True)
group_checkbox.setChecked(new_state)
group_checkbox.blockSignals(False)
def handle_drone_clicked(self, drone_id): def handle_drone_clicked(self, drone_id):
if drone_id in self.drones: """地圖上點擊 drone — 切換其選擇狀態"""
checkbox = self.drones[drone_id].get_checkbox() group = self._get_active_group()
checkbox.setChecked(not checkbox.isChecked()) if not group:
return
def handle_clear_all_drone_selection(self): if drone_id in group.selected_drone_ids:
for drone_id in self.drones.keys(): group.selected_drone_ids.remove(drone_id)
checkbox = self.drones[drone_id].get_checkbox() else:
if checkbox: # 嘗試勾選前先檢查是否被其他 group 使用
checkbox.blockSignals(True) if self._is_drone_selected_by_other_group(drone_id, group.group_id):
checkbox.setChecked(False) self.statusBar().showMessage(
checkbox.blockSignals(False) f"⚠ {drone_id} 已被其他 group 勾選,無法加入 Group {group.group_id}", 3000
self.monitor.selected_drones.clear() )
for socket_id in self.socket_groups.keys(): self.refresh_selection_ui()
self.update_group_checkbox_state(socket_id) return
group.selected_drone_ids.add(drone_id)
self.refresh_selection_ui()
def handle_toggle_select_all_drones(self): def handle_toggle_select_all_drones(self):
all_selected = all(self.drones[did].get_checkbox().isChecked() for did in self.drones.keys()) """全選 / 清空 — 切換 active group 的所有 drone"""
if all_selected: group = self._get_active_group()
for drone_id in self.drones.keys(): if not group:
checkbox = self.drones[drone_id].get_checkbox() return
if checkbox:
checkbox.blockSignals(True) selectable_ids = self._get_selectable_drones_for_group(group.group_id) | set(group.selected_drone_ids)
checkbox.setChecked(False)
checkbox.blockSignals(False) if group.selected_drone_ids == selectable_ids:
self.monitor.selected_drones.clear() # 已全選 → 清空
group.selected_drone_ids.clear()
else: else:
for drone_id in self.drones.keys(): # 未全選 → 全選
checkbox = self.drones[drone_id].get_checkbox() group.selected_drone_ids = set(selectable_ids)
if checkbox:
checkbox.blockSignals(True) self.refresh_selection_ui()
checkbox.setChecked(True)
checkbox.blockSignals(False) def handle_clear_all_drone_selection(self):
self.monitor.selected_drones.add(drone_id) """清除 active group 的所有無人機選擇"""
for socket_id in self.socket_groups.keys(): group = self._get_active_group()
self.update_group_checkbox_state(socket_id) if not group:
return
group.selected_drone_ids.clear()
self.refresh_selection_ui()
self.statusBar().showMessage("已清除所有選擇", 2000)
# ================================================================================ # ================================================================================
# 任務規劃 — 點擊地圖(路由到 active group) # 任務規劃 — 點擊地圖(路由到 active group)
@@ -1176,7 +1279,7 @@ class ControlStationUI(QMainWindow):
def _get_group_drones(self, group): def _get_group_drones(self, group):
"""取得群組的無人機 ID 列表(排序後)""" """取得群組的無人機 ID 列表(排序後)"""
return sorted(group.drone_ids, key=lambda x: (x.split('_')[0], int(x.split('_')[1]))) return sorted(group.selected_drone_ids, key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
def handle_map_click(self, lat, lon): def handle_map_click(self, lat, lon):
"""處理地圖點擊事件 — 根據 active group 的任務類型規劃""" """處理地圖點擊事件 — 根據 active group 的任務類型規劃"""
@@ -1211,11 +1314,15 @@ class ControlStationUI(QMainWindow):
if drone_gps_positions is None: if drone_gps_positions is None:
return return
waypoints_per_drone, center_origin = self.mission_planner.plan_formation_mission( waypoints_per_drone, center_origin, rendezvous_indices = self.mission_planner.plan_formation_mission(
drone_gps_positions, target_gps, mission_type, params=params drone_gps_positions, target_gps, mission_type, params=params
) )
group.planned_waypoints = {'drone_ids': selected_drones, 'waypoints': waypoints_per_drone} group.planned_waypoints = {
'drone_ids': selected_drones,
'waypoints': waypoints_per_drone,
'rendezvous_indices': rendezvous_indices,
}
group.center_origin = center_origin group.center_origin = center_origin
self.show_planned_waypoints(group) self.show_planned_waypoints(group)
@@ -1282,12 +1389,16 @@ class ControlStationUI(QMainWindow):
target_gps = (target_lat, target_lon, base_alt) target_gps = (target_lat, target_lon, base_alt)
params['rect_corners'] = rect_corners params['rect_corners'] = rect_corners
waypoints_per_drone, center_origin = self.mission_planner.plan_formation_mission( waypoints_per_drone, center_origin, rendezvous_indices = self.mission_planner.plan_formation_mission(
drone_gps_positions, target_gps, MissionType.GRID_SWEEP, drone_gps_positions, target_gps, MissionType.GRID_SWEEP,
params=params params=params
) )
group.planned_waypoints = {'drone_ids': selected_drones, 'waypoints': waypoints_per_drone} group.planned_waypoints = {
'drone_ids': selected_drones,
'waypoints': waypoints_per_drone,
'rendezvous_indices': rendezvous_indices,
}
group.center_origin = center_origin group.center_origin = center_origin
self.show_planned_waypoints(group) self.show_planned_waypoints(group)
@@ -1331,6 +1442,11 @@ class ControlStationUI(QMainWindow):
self.statusBar().showMessage(f"⚠ Group {group.group_id}: 請先分配無人機", 3000) self.statusBar().showMessage(f"⚠ Group {group.group_id}: 請先分配無人機", 3000)
return return
# LEADER_FOLLOWER:把指定的領隊放到 rank 0
leader = getattr(group, 'leader_drone_id', None)
if leader and leader in selected_drones:
selected_drones = [leader] + [d for d in selected_drones if d != leader]
print(f"路徑確認 → Group {group.group_id}: {points_json}") print(f"路徑確認 → Group {group.group_id}: {points_json}")
try: try:
route_points = json.loads(points_json) route_points = json.loads(points_json)
@@ -1359,12 +1475,16 @@ class ControlStationUI(QMainWindow):
target_gps = (target_lat, target_lon, base_alt) target_gps = (target_lat, target_lon, base_alt)
params['route_waypoints'] = route_waypoints params['route_waypoints'] = route_waypoints
waypoints_per_drone, center_origin = self.mission_planner.plan_formation_mission( waypoints_per_drone, center_origin, rendezvous_indices = self.mission_planner.plan_formation_mission(
drone_gps_positions, target_gps, MissionType.LEADER_FOLLOWER, drone_gps_positions, target_gps, MissionType.LEADER_FOLLOWER,
params=params params=params
) )
group.planned_waypoints = {'drone_ids': selected_drones, 'waypoints': waypoints_per_drone} group.planned_waypoints = {
'drone_ids': selected_drones,
'waypoints': waypoints_per_drone,
'rendezvous_indices': rendezvous_indices,
}
group.center_origin = center_origin group.center_origin = center_origin
self.show_planned_waypoints(group) self.show_planned_waypoints(group)
@@ -1403,6 +1523,17 @@ class ControlStationUI(QMainWindow):
else: else:
self.statusBar().showMessage(f"📍 {drone_id} → WP {wp_index}/{total}", 2000) self.statusBar().showMessage(f"📍 {drone_id} → WP {wp_index}/{total}", 2000)
def _on_task_status_changed(self, drone_id, status, message):
"""MissionExecutor.task_status_changed slot:把 goto 失敗/重試/fallback/barrier 丟到 status bar"""
if status == "retrying":
self.statusBar().showMessage(f"⚠ {drone_id} {message}", 4000)
elif status == "fallback_loiter":
self.statusBar().showMessage(f"❌ {drone_id} {message}", 8000)
elif status == "waiting_at_barrier":
self.statusBar().showMessage(f"⏸ {drone_id} {message}", 3000)
elif status == "normal":
self.statusBar().showMessage(f"▶ {drone_id} {message or '已恢復'}", 2000)
# ================================================================================ # ================================================================================
# 輔助方法 # 輔助方法
# ================================================================================ # ================================================================================
@@ -1469,9 +1600,6 @@ class ControlStationUI(QMainWindow):
print(f" WP{j}: ({wp[0]:.6f}°, {wp[1]:.6f}°, {wp[2]:.1f}m)") print(f" WP{j}: ({wp[0]:.6f}°, {wp[1]:.6f}°, {wp[2]:.1f}m)")
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
def get_selected_drones(self):
return [did for did, panel in self.drones.items() if hasattr(panel, 'checkbox') and panel.checkbox.isChecked()]
def update_field(self, panel, drone_id, field, text, color=None): def update_field(self, panel, drone_id, field, text, color=None):
if isinstance(panel, DronePanel): if isinstance(panel, DronePanel):
panel.update_field(field, text, color) panel.update_field(field, text, color)
@@ -1495,8 +1623,9 @@ class ControlStationUI(QMainWindow):
self.socket_groups[socket_id] = self.create_socket_group_panel(socket_id) self.socket_groups[socket_id] = self.create_socket_group_panel(socket_id)
self.socket_groups[socket_id].drones_layout.addWidget(panel) self.socket_groups[socket_id].drones_layout.addWidget(panel)
self.reorganize_socket_groups() self.reorganize_socket_groups()
self.update_group_checkbox_state(socket_id)
self.update_overview_table() self.update_overview_table()
# 同步新 drone 到 UI
self.refresh_selection_ui()
def reorganize_socket_groups(self): def reorganize_socket_groups(self):
while self.drone_panel_layout.count(): while self.drone_panel_layout.count():
@@ -1723,10 +1852,16 @@ class ControlStationUI(QMainWindow):
# Clean up serial receivers # Clean up serial receivers
for receiver in self.monitor.serial_receivers: for receiver in self.monitor.serial_receivers:
receiver.stop() receiver.stop()
# Clean up CommandLongClient # Clean up all CommandLongClient instances
if self.monitor.command_long_client: for drone_id, client in self.monitor.command_long_clients.items():
try: try:
self.monitor.command_long_client.destroy_node() client.destroy_node()
except:
pass
# Clean up all PositionTargetGlobalIntClient instances
for drone_id, client in getattr(self.monitor, 'position_target_clients', {}).items():
try:
client.destroy_node()
except: except:
pass pass
self.monitor.destroy_node() self.monitor.destroy_node()
+221 -50
View File
@@ -6,11 +6,15 @@
設計: 設計:
- 每架無人機持有一個航點序列,逐點推進 - 每架無人機持有一個航點序列,逐點推進
- 各自到達就各自切換到下一個航點 - 各自到達就各自切換到下一個航點
- 用 QTimer 驅動,在 Qt 主線程執行 - 事件驅動發送:航點切換時送一次,收到 send_result 才決定重送/前進
- 暫停 = 停止發送 setpoint,飛控自動懸停 - 失敗策略 (b):單機重試 MAX_RETRY 次仍失敗 → 該機 fallback LOITER,其他機繼續
- 相容舊的 2 階段任務與新的多航點任務 (Grid Sweep) - Rendezvous barrier:在指定 wp index 等所有活著的機到齊才一起推進,有 timeout 保護
- 用 QTimer 驅動到達判定,在 Qt 主線程執行
- 暫停 = 停止到達判定 + 停止新指令
""" """
import asyncio
import math import math
import time
from enum import Enum from enum import Enum
from PyQt6.QtCore import QObject, QTimer, pyqtSignal from PyQt6.QtCore import QObject, QTimer, pyqtSignal
@@ -22,22 +26,31 @@ class MissionState(Enum):
PAUSED = "paused" PAUSED = "paused"
class TaskStatus(Enum):
"""單機任務狀態"""
NORMAL = "normal"
RETRYING = "retrying"
WAITING_AT_BARRIER = "waiting_at_barrier"
FALLBACK_LOITER = "fallback_loiter"
class DroneTask: class DroneTask:
"""單架無人機的任務資料""" """單架無人機的任務資料"""
__slots__ = ('drone_id', 'sysid', 'waypoints', 'wp_index', 'done') __slots__ = (
'drone_id', 'sysid', 'waypoints', 'wp_index', 'done',
'sent_current_wp', 'fail_count', 'status', 'waiting_since',
)
def __init__(self, drone_id, sysid, waypoints): def __init__(self, drone_id, sysid, waypoints):
"""
Args:
drone_id: GUI 用的 ID (如 's0_11')
sysid: MAVLink system ID (如 11)
waypoints: 航點序列 [(lat, lon, alt), ...]
"""
self.drone_id = drone_id self.drone_id = drone_id
self.sysid = sysid self.sysid = sysid
self.waypoints = waypoints self.waypoints = waypoints
self.wp_index = 0 self.wp_index = 0
self.done = len(waypoints) == 0 self.done = len(waypoints) == 0
self.sent_current_wp = False
self.fail_count = 0
self.status = TaskStatus.NORMAL
self.waiting_since = 0.0 # monotonic time 進入 WAITING_AT_BARRIER 的瞬間
@property @property
def current_target(self): def current_target(self):
@@ -52,7 +65,7 @@ class DroneTask:
class MissionExecutor(QObject): class MissionExecutor(QObject):
""" """
任務執行器 任務執行器 (事件驅動版 + rendezvous barrier)
planned_waypoints 格式: planned_waypoints 格式:
{ {
@@ -60,31 +73,41 @@ class MissionExecutor(QObject):
'waypoints': [ 'waypoints': [
[(lat,lon,alt), ...], # drone 0 [(lat,lon,alt), ...], # drone 0
[(lat,lon,alt), ...], # drone 1 [(lat,lon,alt), ...], # drone 1
] ],
'rendezvous_indices': [3, 7, ...], # 選填;缺省 = 全程不同步(各自跑)
} }
""" """
# 信號 MAX_RETRY = 3
drone_waypoint_reached = pyqtSignal(str, int, int) # (drone_id, wp_index, total)
drone_waypoint_reached = pyqtSignal(str, int, int) # (drone_id, wp_index, total)
task_status_changed = pyqtSignal(str, str, str) # (drone_id, status, message)
mission_completed = pyqtSignal() mission_completed = pyqtSignal()
def __init__(self, sender, drone_gps, def __init__(self, sender, drone_gps, monitor=None,
arrival_radius=2.0, send_rate_hz=2.0): arrival_radius=2.0, tick_rate_hz=2.0,
barrier_timeout_sec=20.0):
super().__init__() super().__init__()
self.sender = sender self.sender = sender
self.drone_gps = drone_gps self.drone_gps = drone_gps
self.monitor = monitor # 用於失敗 fallback 到 LOITER
self.arrival_radius = arrival_radius self.arrival_radius = arrival_radius
self.barrier_timeout_sec = barrier_timeout_sec
self.state = MissionState.IDLE self.state = MissionState.IDLE
self.tasks = {} self.tasks = {}
self.rendezvous_indices = set()
self._interval_ms = int(1000 / send_rate_hz) self._interval_ms = int(1000 / tick_rate_hz)
self._timer = QTimer() self._timer = QTimer()
self._timer.timeout.connect(self._tick) self._timer.timeout.connect(self._tick)
# 只有 Ros2CommandSender 有 send_result signal;MavlinkSender 沒有
if hasattr(sender, 'send_result'):
sender.send_result.connect(self._on_send_result)
# ------------------------------------------------------------------ 公開方法 # ------------------------------------------------------------------ 公開方法
def start(self, planned_waypoints): def start(self, planned_waypoints):
"""啟動任務"""
if self.state == MissionState.RUNNING: if self.state == MissionState.RUNNING:
print("任務已在執行中") print("任務已在執行中")
return return
@@ -93,6 +116,9 @@ class MissionExecutor(QObject):
drone_ids = planned_waypoints['drone_ids'] drone_ids = planned_waypoints['drone_ids']
waypoints_list = planned_waypoints['waypoints'] waypoints_list = planned_waypoints['waypoints']
self.rendezvous_indices = set(
planned_waypoints.get('rendezvous_indices', []) or []
)
for i, drone_id in enumerate(drone_ids): for i, drone_id in enumerate(drone_ids):
sysid = int(drone_id.split('_')[1]) sysid = int(drone_id.split('_')[1])
@@ -102,87 +128,232 @@ class MissionExecutor(QObject):
self._timer.start(self._interval_ms) self._timer.start(self._interval_ms)
total_wps = sum(t.total_waypoints for t in self.tasks.values()) total_wps = sum(t.total_waypoints for t in self.tasks.values())
rv_info = (
f"rendezvous WP={sorted(self.rendezvous_indices)}"
if self.rendezvous_indices else "無 rendezvous (各自跑)"
)
print(f"任務啟動: {len(self.tasks)} 架無人機, " print(f"任務啟動: {len(self.tasks)} 架無人機, "
f"共 {total_wps} 個航點, " f"共 {total_wps} 個航點, "
f"到達半徑={self.arrival_radius}m, " f"到達半徑={self.arrival_radius}m, "
f"發送週期={self._interval_ms}ms") f"tick 週期={self._interval_ms}ms, "
f"barrier timeout={self.barrier_timeout_sec}s, "
f"{rv_info}")
def pause(self): def pause(self):
"""暫停任務"""
if self.state == MissionState.RUNNING: if self.state == MissionState.RUNNING:
self._timer.stop() self._timer.stop()
self.state = MissionState.PAUSED self.state = MissionState.PAUSED
print("任務暫停") print("任務暫停")
def resume(self): def resume(self):
"""恢復任務"""
if self.state == MissionState.PAUSED: if self.state == MissionState.PAUSED:
self._timer.start(self._interval_ms) self._timer.start(self._interval_ms)
self.state = MissionState.RUNNING self.state = MissionState.RUNNING
print("任務恢復") print("任務恢復")
def stop(self): def stop(self):
"""停止任務"""
self._timer.stop() self._timer.stop()
self.tasks.clear() self.tasks.clear()
self.rendezvous_indices = set()
self.state = MissionState.IDLE self.state = MissionState.IDLE
print("任務停止") print("任務停止")
# ------------------------------------------------------------------ 控制迴圈 # ------------------------------------------------------------------ 控制迴圈
def _tick(self): def _tick(self):
"""控制迴圈""" """
all_done = True 控制 tick:
Phase 1: 每台機做到達判定;非 barrier 點直接推進,barrier 點改成 WAITING
Phase 2: 檢查 barrier 能不能釋放(全員到齊或 timeout)
Phase 3: 每台未送過當前航點的機送一次
Phase 4: 全部完成檢查
"""
now = time.monotonic()
# ---- Phase 1: 到達判定 ----
for task in self.tasks.values(): for task in self.tasks.values():
if task.done: if task.done or task.status == TaskStatus.FALLBACK_LOITER:
continue
# 已在 barrier 等待的不再跑到達判定
if task.status == TaskStatus.WAITING_AT_BARRIER:
continue continue
all_done = False
target = task.current_target target = task.current_target
if target is None: if target is None:
continue continue
# 讀取當前 GPS
gps = self.drone_gps.get(task.drone_id) gps = self.drone_gps.get(task.drone_id)
if gps is None: if gps is None:
continue continue
tgt_lat, tgt_lon, tgt_alt = target tgt_lat, tgt_lon, _ = target
distance = _haversine(gps['lat'], gps['lon'], tgt_lat, tgt_lon) distance = _haversine(gps['lat'], gps['lon'], tgt_lat, tgt_lon)
if distance >= self.arrival_radius:
continue
# 到達判定 # 到達當前 wp_index
if distance < self.arrival_radius: if (task.wp_index in self.rendezvous_indices
task.wp_index += 1 and task.wp_index < task.total_waypoints - 1):
if task.wp_index >= task.total_waypoints: # rendezvous 點 → 不推進,進入 barrier 等待
task.done = True task.status = TaskStatus.WAITING_AT_BARRIER
self.drone_waypoint_reached.emit( task.waiting_since = now
task.drone_id, task.wp_index, task.total_waypoints print(f" {task.drone_id} → 抵達 barrier WP {task.wp_index},等待同伴")
) self.task_status_changed.emit(
print(f" {task.drone_id} → DONE " task.drone_id, task.status.value,
f"({task.total_waypoints}/{task.total_waypoints})") f"waiting at WP {task.wp_index}"
continue )
else: continue
self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints
)
print(f" {task.drone_id} → WP {task.wp_index}/{task.total_waypoints} "
f"(距離: {distance:.1f}m)")
# 更新目標
tgt_lat, tgt_lon, tgt_alt = task.current_target
# 發送 setpoint # 一般點 → 直接推進
self._advance_waypoint(task, distance)
# ---- Phase 2: barrier 釋放檢查 ----
self._check_barriers(now)
# ---- Phase 3: 發送未送過的目標 ----
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
tgt_lat, tgt_lon, tgt_alt = target
task.sent_current_wp = True
self.sender.send_position_global( self.sender.send_position_global(
task.sysid, tgt_lat, tgt_lon, tgt_alt task.drone_id, task.sysid, tgt_lat, tgt_lon, tgt_alt
) )
# 全部完成檢查 # ---- Phase 4: 完成檢查 ----
all_done = all(
t.done or t.status == TaskStatus.FALLBACK_LOITER
for t in self.tasks.values()
)
if all_done and self.tasks: if all_done and self.tasks:
self._timer.stop() self._timer.stop()
self.state = MissionState.IDLE self.state = MissionState.IDLE
self.mission_completed.emit() self.mission_completed.emit()
print("===== 任務全部完成 =====") print("===== 任務全部完成 =====")
def _advance_waypoint(self, task, arrived_distance):
"""把 task 推進一個航點,重置發送旗標。不處理 barrier 邏輯。"""
task.wp_index += 1
task.sent_current_wp = False
task.fail_count = 0
if task.wp_index >= task.total_waypoints:
task.done = True
self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints
)
print(f" {task.drone_id} → DONE "
f"({task.total_waypoints}/{task.total_waypoints})")
return
self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints
)
print(f" {task.drone_id} → WP {task.wp_index}/{task.total_waypoints} "
f"(到達距離: {arrived_distance:.1f}m)")
def _check_barriers(self, now):
"""檢查每個有人在等的 barrier 能不能釋放。"""
# 蒐集目前有哪些 barrier 有人卡著
waiting_groups = {} # barrier_idx -> [task, ...]
for task in self.tasks.values():
if task.status == TaskStatus.WAITING_AT_BARRIER:
waiting_groups.setdefault(task.wp_index, []).append(task)
if not waiting_groups:
return
for barrier_idx, waiting_tasks in waiting_groups.items():
# 判斷是否「全員到齊」:所有仍活著的機都要麼在此 barrier 等,要麼早已越過此 idx
everyone_arrived = True
for task in self.tasks.values():
if task.done or task.status == TaskStatus.FALLBACK_LOITER:
continue # 失敗機/完成機不擋
if (task.status == TaskStatus.WAITING_AT_BARRIER
and task.wp_index == barrier_idx):
continue # 在這裡等
if task.wp_index > barrier_idx:
continue # 早已越過(barrier 先前已對他釋放)
# 還在路上
everyone_arrived = False
break
force_reason = None
if not everyone_arrived:
# 檢查 timeout:以該 barrier 上最早進入等待的時間為準
oldest_wait = min(t.waiting_since for t in waiting_tasks)
if now - oldest_wait >= self.barrier_timeout_sec:
force_reason = f"timeout {self.barrier_timeout_sec:.0f}s"
print(f"⚠️ barrier WP {barrier_idx} {force_reason},強制放行")
else:
continue # 還沒到齊、也還沒 timeout → 繼續等
# 釋放:把所有在此 barrier 等待的機一起推進
tag = "全員到齊" if force_reason is None else force_reason
print(f"✅ barrier WP {barrier_idx} 釋放 ({tag}),"
f"推進 {len(waiting_tasks)} 架")
for task in waiting_tasks:
task.status = TaskStatus.NORMAL
msg = f"barrier WP {barrier_idx} released ({tag})"
self.task_status_changed.emit(
task.drone_id, task.status.value, msg
)
self._advance_waypoint(task, self.arrival_radius)
# ------------------------------------------------------------------ 結果回呼
def _on_send_result(self, drone_id, sysid, success, message):
"""Ros2CommandSender.send_result 的 slot"""
task = self.tasks.get(drone_id)
if task is None or task.done:
return
# 若已在 barrier 等待,舊指令的遲到回應不要再觸發重試/fallback
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")
task.fail_count = 0
return
task.fail_count += 1
print(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}"
)
print(f"❌ {drone_id} 連續失敗 {self.MAX_RETRY} 次 → 切換 LOITER")
self._fallback_to_loiter(drone_id)
def _fallback_to_loiter(self, drone_id):
"""用 monitor.set_mode 切 LOITER。set_mode 是 coroutine,透過 event loop 派送。"""
if self.monitor is None:
print(f"⚠️ 無 monitor,無法將 {drone_id} 切到 LOITER")
return
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.create_task(self.monitor.set_mode(drone_id, "LOITER"))
# ------------------------------------------------------------------ 工具函式 # ------------------------------------------------------------------ 工具函式
+62 -25
View File
@@ -28,13 +28,18 @@ class MissionGroup:
"""單一任務群組的資料""" """單一任務群組的資料"""
def __init__(self, group_id, color): def __init__(self, group_id, color):
self.group_id = group_id # 'A', 'B', 'C', ... self.group_id = group_id # 'A', 'B', 'C', ...
self.color = color # 顏色 hex self.color = color # 顏色 hex
self.drone_ids = set() # 已分配的無人機 ID
self.mission_type = 'M_FORMATION' # 預設任務類型 # 唯一真實資料來源:該 group 選中的 drone ID
self.planned_waypoints = None # 規劃結果 dict # 代表:group 擁有的 drone、被選中的 drone、可以操作的 drone
self.executor = None # MissionExecutor 實例(延遲建立) self.selected_drone_ids = set()
self.center_origin = None # 規劃原點
self.mission_type = 'M_FORMATION' # 預設任務類型
self.planned_waypoints = None # 規劃結果 dict
self.executor = None # MissionExecutor 實例(延遲建立)
self.center_origin = None # 規劃原點
self.leader_drone_id = None # LEADER_FOLLOWER 專用:指定的領隊無人機 ID
@property @property
def state(self): def state(self):
@@ -50,13 +55,13 @@ class MissionGroup:
class DroneAssignDialog(QDialog): class DroneAssignDialog(QDialog):
"""無人機分配對話框""" """無人機分配對話框"""
def __init__(self, parent, all_drone_ids, current_assigned, other_assigned): def __init__(self, parent, all_drone_ids, current_assigned, other_assigned=None):
""" """
Args: Args:
parent: 父 widget parent: 父 widget
all_drone_ids: 所有可用無人機 ID 列表 all_drone_ids: 所有可用無人機 ID 列表
current_assigned: 當前群組已分配的無人機 set current_assigned: 當前群組已分配的無人機 set
other_assigned: 其他群組已佔用的無人機 dict {drone_id: group_id} other_assigned: (忽略 — 現在允許多 group 分配)
""" """
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("分配無人機") self.setWindowTitle("分配無人機")
@@ -66,7 +71,6 @@ class DroneAssignDialog(QDialog):
QLabel { color: #DDD; } QLabel { color: #DDD; }
QCheckBox { color: #DDD; spacing: 8px; padding: 4px; } QCheckBox { color: #DDD; spacing: 8px; padding: 4px; }
QCheckBox::indicator { width: 16px; height: 16px; } QCheckBox::indicator { width: 16px; height: 16px; }
QCheckBox:disabled { color: #666; }
""") """)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -89,12 +93,9 @@ class DroneAssignDialog(QDialog):
for drone_id in sorted_ids: for drone_id in sorted_ids:
cb = QCheckBox(drone_id) cb = QCheckBox(drone_id)
# 所有 drone 都能被選摘(支持多 group 分配)
if drone_id in current_assigned: if drone_id in current_assigned:
cb.setChecked(True) cb.setChecked(True)
elif drone_id in other_assigned:
cb.setEnabled(False)
cb.setToolTip(f"已分配到 Group {other_assigned[drone_id]}")
cb.setText(f"{drone_id} (Group {other_assigned[drone_id]})")
self.checkboxes[drone_id] = cb self.checkboxes[drone_id] = cb
scroll_layout.addWidget(cb) scroll_layout.addWidget(cb)
@@ -159,7 +160,6 @@ class GroupPanel(QWidget):
def __init__(self, group: MissionGroup, parent=None): def __init__(self, group: MissionGroup, parent=None):
super().__init__(parent) super().__init__(parent)
self.group = group self.group = group
self._is_all_selected = False # 追蹤全選狀態
self.all_btn_ref = None # 保存全選按鈕的參考 self.all_btn_ref = None # 保存全選按鈕的參考
self._build_ui() self._build_ui()
@@ -344,13 +344,13 @@ class GroupPanel(QWidget):
clear_btn.setStyleSheet(BTN.format(bg='#777', fg='white', hover='#888')) clear_btn.setStyleSheet(BTN.format(bg='#777', fg='white', hover='#888'))
clear_btn.clicked.connect( clear_btn.clicked.connect(
lambda: self.clear_group_requested.emit(self.group.group_id)) lambda: self.clear_group_requested.emit(self.group.group_id))
delete_group_btn = QPushButton("刪除群組") self.delete_group_btn = QPushButton("刪除群組")
delete_group_btn.setStyleSheet(BTN.format(bg='#EF5350', fg='white', hover='#E53935')) self.delete_group_btn.setStyleSheet(BTN.format(bg='#EF5350', fg='white', hover='#E53935'))
delete_group_btn.clicked.connect( self.delete_group_btn.clicked.connect(
lambda: self.delete_group_requested.emit(self.group.group_id)) lambda: self.delete_group_requested.emit(self.group.group_id))
grid_r2.addWidget(assign_btn) grid_r2.addWidget(assign_btn)
grid_r2.addWidget(clear_btn) grid_r2.addWidget(clear_btn)
grid_r2.addWidget(delete_group_btn) grid_r2.addWidget(self.delete_group_btn)
right.addLayout(grid_r2) right.addLayout(grid_r2)
right.addStretch() right.addStretch()
@@ -415,6 +415,21 @@ class GroupPanel(QWidget):
self._param_widgets[key] = (row_w, inp) self._param_widgets[key] = (row_w, inp)
self._param_rows.append(row_w) self._param_rows.append(row_w)
# LEADER_FOLLOWER 專用:領隊下拉選單
self._leader_row = QWidget()
leader_layout = QHBoxLayout(self._leader_row)
leader_layout.setContentsMargins(0, 0, 0, 0)
leader_layout.setSpacing(3)
leader_lbl = QLabel("領隊")
leader_lbl.setStyleSheet(LBL)
self._leader_combo = QComboBox()
self._leader_combo.setStyleSheet(COMBO)
self._leader_combo.setFixedWidth(70)
self._leader_combo.currentTextChanged.connect(self._on_leader_changed)
leader_layout.addWidget(leader_lbl, 1)
leader_layout.addWidget(self._leader_combo)
param_col.addWidget(self._leader_row)
param_col.addStretch() param_col.addStretch()
# 初始顯示對應的參數 # 初始顯示對應的參數
@@ -438,15 +453,36 @@ class GroupPanel(QWidget):
def update_drone_list(self): def update_drone_list(self):
"""更新無人機列表顯示""" """更新無人機列表顯示"""
if not self.group.drone_ids: if not self.group.selected_drone_ids:
self.drone_list_label.setText("尚未分配") self.drone_list_label.setText("尚未分配")
self.drone_list_label.setStyleSheet("color: #888; font-size: 11px;") self.drone_list_label.setStyleSheet("color: #888; font-size: 11px;")
else: else:
sorted_ids = sorted(self.group.drone_ids, sorted_ids = sorted(self.group.selected_drone_ids,
key=lambda x: (x.split('_')[0], int(x.split('_')[1]))) key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
self.drone_list_label.setText("、".join(sorted_ids)) self.drone_list_label.setText("、".join(sorted_ids))
self.drone_list_label.setStyleSheet( self.drone_list_label.setStyleSheet(
f"color: {self.group.color}; font-size: 11px; font-weight: bold;") f"color: {self.group.color}; font-size: 11px; font-weight: bold;")
self._refresh_leader_options()
def _refresh_leader_options(self):
"""依目前群組成員重新填充領隊下拉選單,保留目前選擇若仍有效"""
sorted_ids = sorted(self.group.selected_drone_ids,
key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
current = self.group.leader_drone_id
self._leader_combo.blockSignals(True)
self._leader_combo.clear()
self._leader_combo.addItems(sorted_ids)
if current and current in sorted_ids:
self._leader_combo.setCurrentText(current)
elif sorted_ids:
self._leader_combo.setCurrentText(sorted_ids[0])
self.group.leader_drone_id = sorted_ids[0]
else:
self.group.leader_drone_id = None
self._leader_combo.blockSignals(False)
def _on_leader_changed(self, drone_id):
self.group.leader_drone_id = drone_id if drone_id else None
def update_status(self): def update_status(self):
"""更新任務狀態顯示""" """更新任務狀態顯示"""
@@ -461,7 +497,7 @@ class GroupPanel(QWidget):
self.status_label.setText("⏸ 已暫停") self.status_label.setText("⏸ 已暫停")
self.status_label.setStyleSheet("color: #FFA000; font-size: 11px; font-weight: bold;") self.status_label.setStyleSheet("color: #FFA000; font-size: 11px; font-weight: bold;")
else: else:
n = len(self.group.drone_ids) n = len(self.group.selected_drone_ids)
total_wps = sum(len(wps) for wps in self.group.planned_waypoints['waypoints']) total_wps = sum(len(wps) for wps in self.group.planned_waypoints['waypoints'])
self.status_label.setText(f"● 已規劃 ({n}架/{total_wps}點)") self.status_label.setText(f"● 已規劃 ({n}架/{total_wps}點)")
self.status_label.setStyleSheet( self.status_label.setStyleSheet(
@@ -471,9 +507,9 @@ class GroupPanel(QWidget):
"""全選按鈕點擊 - 發送信號給 gui.py 處理 toggle 邏輯""" """全選按鈕點擊 - 發送信號給 gui.py 處理 toggle 邏輯"""
self.select_all_requested.emit(self.group.group_id) self.select_all_requested.emit(self.group.group_id)
def set_all_select_state(self, is_selected): def set_delete_enabled(self, enabled):
"""外部設置全選狀態(按鈕文本保持「全選/取消」)""" """啟用或禁用刪除群組按鈕"""
self._is_all_selected = is_selected self.delete_group_btn.setEnabled(enabled)
def _update_param_visibility(self, _=None): def _update_param_visibility(self, _=None):
"""根據當前任務類型,顯示/隱藏對應的參數列""" """根據當前任務類型,顯示/隱藏對應的參數列"""
@@ -481,6 +517,7 @@ class GroupPanel(QWidget):
visible_keys = {d[0] for d in self._param_defs.get(mission_type, [])} visible_keys = {d[0] for d in self._param_defs.get(mission_type, [])}
for key, (row_w, _inp) in self._param_widgets.items(): for key, (row_w, _inp) in self._param_widgets.items():
row_w.setVisible(key in visible_keys) row_w.setVisible(key in visible_keys)
self._leader_row.setVisible(mission_type == 'LEADER_FOLLOWER')
def get_mission_params(self): def get_mission_params(self):
"""讀取當前顯示的參數值,回傳 dict""" """讀取當前顯示的參數值,回傳 dict"""
+285 -120
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
飛行任務規劃模組 飛行任務規劃模組
支援: M-Formation, Circle, Leader-Follower (Bezier 轉彎), Grid Sweep 支援: M-Formation, Circle, Leader-Follower (arc-length virtual leader), Grid Sweep
""" """
import math import math
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
@@ -72,7 +72,12 @@ class FormationPlanner:
mission_type: MissionType = MissionType.M_FORMATION, mission_type: MissionType = MissionType.M_FORMATION,
params: Optional[Dict[str, Any]] = None params: Optional[Dict[str, Any]] = None
) -> Tuple[List[List[Tuple[float, float, float]]], ) -> Tuple[List[List[Tuple[float, float, float]]],
Tuple[float, float, float]]: Tuple[float, float, float],
List[int]]:
"""
回傳 (waypoints_gps, origin, rendezvous_indices)。
rendezvous_indices:航點序列裡該群組需要等全員到齊才推進的 index 清單。
"""
if len(drone_gps_positions) == 0: if len(drone_gps_positions) == 0:
raise ValueError("無人機位置列表不能為空") raise ValueError("無人機位置列表不能為空")
@@ -89,10 +94,14 @@ class FormationPlanner:
if mission_type == MissionType.M_FORMATION: if mission_type == MissionType.M_FORMATION:
s1, s2 = self._calculate_m_formation(drone_local, target_local, params) s1, s2 = self._calculate_m_formation(drone_local, target_local, params)
waypoints_local = [[s1[i], s2[i]] for i in range(len(drone_local))] waypoints_local = [[s1[i], s2[i]] for i in range(len(drone_local))]
# 起點集合後一起出發到 stage2
rendezvous_indices = [0]
elif mission_type == MissionType.CIRCLE_FORMATION: elif mission_type == MissionType.CIRCLE_FORMATION:
s1, s2 = self._calculate_circle_formation(drone_local, target_local, params) s1, s2 = self._calculate_circle_formation(drone_local, target_local, params)
waypoints_local = [[s1[i], s2[i]] for i in range(len(drone_local))] waypoints_local = [[s1[i], s2[i]] for i in range(len(drone_local))]
# 半程集合後一起進最終圓環位置
rendezvous_indices = [0]
elif mission_type == MissionType.LEADER_FOLLOWER: elif mission_type == MissionType.LEADER_FOLLOWER:
params = params or {} params = params or {}
@@ -103,7 +112,9 @@ class FormationPlanner:
self.converter.gps_to_local(wp[0], wp[1], 0)[:2] self.converter.gps_to_local(wp[0], wp[1], 0)[:2]
for wp in route_wps_gps for wp in route_wps_gps
] ]
waypoints_local = self._calculate_leader_follower(drone_local, route_wps_local, params) waypoints_local, rendezvous_indices = self._calculate_leader_follower(
drone_local, route_wps_local, params
)
elif mission_type == MissionType.GRID_SWEEP: elif mission_type == MissionType.GRID_SWEEP:
params = params or {} params = params or {}
@@ -114,7 +125,9 @@ class FormationPlanner:
self.converter.gps_to_local(c[0], c[1], 0)[:2] self.converter.gps_to_local(c[0], c[1], 0)[:2]
for c in rect_corners_gps for c in rect_corners_gps
] ]
waypoints_local = self._calculate_grid_sweep(drone_local, rect_corners_local, params) waypoints_local, rendezvous_indices = self._calculate_grid_sweep(
drone_local, rect_corners_local, params
)
else: else:
raise ValueError(f"不支援的任務類型: {mission_type}") raise ValueError(f"不支援的任務類型: {mission_type}")
@@ -123,7 +136,7 @@ class FormationPlanner:
gps_wps = [self.converter.local_to_gps(*wp) for wp in drone_wps] gps_wps = [self.converter.local_to_gps(*wp) for wp in drone_wps]
waypoints_gps.append(gps_wps) waypoints_gps.append(gps_wps)
return waypoints_gps, self.current_origin return waypoints_gps, self.current_origin, rendezvous_indices
# ------------------------------------------------------------------ M-Formation # ------------------------------------------------------------------ M-Formation
@@ -189,177 +202,321 @@ class FormationPlanner:
return stage1_positions, stage2_positions return stage1_positions, stage2_positions
# ------------------------------------------------------------------ 路徑跟隨 (Bezier 轉彎) # ------------------------------------------------------------------ 路徑跟隨 (虛擬領隊)
def _calculate_leader_follower(self, drone_positions, route_wps_local, params): def _calculate_leader_follower(self, drone_positions, route_wps_local, params):
""" """
路徑跟隨編隊 — Bezier 曲線轉彎版 路徑跟隨編隊 — 虛擬領隊 / 弧長參數化版 (virtual leader / arc-length)
步驟: 核心想法:
1. _build_center_path: 在轉折 WP 處用二次 Bezier 曲線平滑轉彎 - center_path 建好後計算每個 sample 的累積弧長 s
2. 固定排序,每架無人機沿中心路徑套用橫向+縱向偏移 - 每架 drone 給 (lat_k, lon_k) 的隊形偏移
- 把 leader 想成沿 s 參數化的點,follower k 的目標 s = s_leader - lon_k
- follower k 第 i 個 waypoint = lookup(s_list[i] - lon_k) + lat_k × perp(tangent)
- 超過 path 起點/終點以 clip 處理,避免倒退或衝出
隊形 (俯視): 解決前版「空間偏移」的三個 bug:
| (前進方向) | 1. 起點暴衝:s<0 clip → follower 起點就是 start 加橫向偏移,不往後倒退
| D1 | ← 左偏移, 後 0m 2. 弧段角度爆炸:lon 不再換算成弧度,直接沿 polyline lookup
| D2 | ← 右偏移, 後 5m 3. straight/arc 切換不連續:統一走 polyline 線性內插與 segment 切線
| D3 | ← 左偏移, 後 10m
| D4 | ← 右偏移, 後 15m Rank 定序規則:
**以 drone_positions 的輸入順序為準**(= GUI 選取順序)。
drone_positions[0] = rank 0 = leader (lon=0),
drone_positions[1] = rank 1 (lon=5),依此類推。
刻意「不」用位置投影自動排序,避免浮點噪音造成 run-to-run
leader 漂移,以確保整個 mission 像軍隊縱隊那樣順序固定。
隊形 (俯視, 以路徑行進方向為前):
D0 (lat=-L, lon=0) ← front-right (leader)
D1 (lat=+L, lon=5)
D2 (lat=-L, lon=10)
D3 (lat=+L, lon=15)
""" """
N = len(drone_positions) N = len(drone_positions)
lateral_offset = params.get('lateral_offset', 3.0) lateral_offset = params.get('lateral_offset', 3.0)
longitudinal_spacing = params.get('longitudinal_spacing', 5.0) longitudinal_spacing = params.get('longitudinal_spacing', 5.0)
altitude = params.get('altitude', self.base_altitude) altitude = params.get('altitude', self.base_altitude)
turn_margin = params.get('turn_margin', 0.35) # 轉彎切入距離佔段長比例 min_inner_radius = params.get('min_inner_radius', 3.0)
curve_resolution = params.get('curve_resolution', 8) # 每個彎道的插值點數 arc_resolution = params.get('arc_resolution', 12)
sharp_turn_cos = params.get('sharp_turn_cos_threshold', 0.0) # cos(90°)=0
# Step 1: 建立帶 Bezier 轉彎的中心路徑 # Step 1: 建立中心路徑(含圓弧、銳角單點;每點帶累積 s)
center_path = self._build_center_path( center_path, barrier_indices = self._build_center_path(
route_wps_local, turn_margin, curve_resolution route_wps_local,
formation_max_lateral=lateral_offset,
min_inner_radius=min_inner_radius,
arc_resolution=arc_resolution,
sharp_turn_cos_threshold=sharp_turn_cos,
) )
# Step 2: 固定排序 s_list = [pt['s'] for pt in center_path]
first_dir = (center_path[0][2], center_path[0][3]) s_max = s_list[-1]
first_perp = (-first_dir[1], first_dir[0]) n_pts = len(center_path)
C_x = sum(p[0] for p in drone_positions) / N
C_y = sum(p[1] for p in drone_positions) / N
projections = [ def lookup(s_target):
((pos[0] - C_x) * first_perp[0] + (pos[1] - C_y) * first_perp[1], i) """
for i, pos in enumerate(drone_positions) 在 center path 上以弧長 s 回傳 (x, y, tangent_dir)。
] s<0 → clip 到 start;s>s_max → clip 到 end。
projections.sort() tangent 方向取當前 polyline segment 的切線,避免對不連續的 dir 做插值。
"""
if n_pts == 1:
pt = center_path[0]
return pt['x'], pt['y'], pt['dir']
# Step 3: 偏移 if s_target <= 0.0:
a = center_path[0]
b = center_path[1]
sdx, sdy = b['x'] - a['x'], b['y'] - a['y']
slen = math.hypot(sdx, sdy)
if slen > 1e-6:
return a['x'], a['y'], (sdx / slen, sdy / slen)
return a['x'], a['y'], a['dir']
if s_target >= s_max:
a = center_path[-2]
b = center_path[-1]
sdx, sdy = b['x'] - a['x'], b['y'] - a['y']
slen = math.hypot(sdx, sdy)
if slen > 1e-6:
return b['x'], b['y'], (sdx / slen, sdy / slen)
return b['x'], b['y'], b['dir']
# Binary search:s_list[lo] <= s_target < s_list[hi]
lo, hi = 0, n_pts - 1
while lo + 1 < hi:
mid = (lo + hi) // 2
if s_list[mid] <= s_target:
lo = mid
else:
hi = mid
a = center_path[lo]
b = center_path[hi]
ds = s_list[hi] - s_list[lo]
if ds < 1e-9:
return a['x'], a['y'], a['dir']
t = (s_target - s_list[lo]) / ds
x = a['x'] + t * (b['x'] - a['x'])
y = a['y'] + t * (b['y'] - a['y'])
sdx, sdy = b['x'] - a['x'], b['y'] - a['y']
slen = math.hypot(sdx, sdy)
if slen > 1e-6:
return x, y, (sdx / slen, sdy / slen)
return x, y, a['dir']
# Step 2: rank = 輸入順序(GUI 選取順序),不再做 projection sort
# 避免浮點噪音在投影值相近時翻轉 leader,保證 run-to-run 穩定
all_waypoints = [None] * N all_waypoints = [None] * N
for rank, (_, original_idx) in enumerate(projections): # 預先算好路徑終點的切線(給收尾點用)
end = center_path[-1]
end_dx, end_dy = end['dir']
if n_pts >= 2:
a = center_path[-2]
sdx, sdy = end['x'] - a['x'], end['y'] - a['y']
slen = math.hypot(sdx, sdy)
if slen > 1e-6:
end_dx, end_dy = sdx / slen, sdy / slen
for rank in range(N):
lat_sign = -1 if rank % 2 == 0 else 1 lat_sign = -1 if rank % 2 == 0 else 1
lat = lat_sign * lateral_offset lat = lat_sign * lateral_offset
lon = rank * longitudinal_spacing lon = rank * longitudinal_spacing
waypoints = [] waypoints = []
for (cx, cy, dx, dy) in center_path: for i in range(n_pts):
s_follower = s_list[i] - lon
x, y, (dx, dy) = lookup(s_follower)
perp_x, perp_y = -dy, dx perp_x, perp_y = -dy, dx
ox = cx + lat * perp_x - lon * dx waypoints.append((
oy = cy + lat * perp_y - lon * dy x + lat * perp_x,
waypoints.append((ox, oy, altitude)) y + lat * perp_y,
altitude,
))
all_waypoints[original_idx] = waypoints # 收尾:全員到 path 終點的 rank 位置(lon 歸零)
waypoints.append((
end['x'] + lat * (-end_dy),
end['y'] + lat * end_dx,
altitude,
))
return all_waypoints all_waypoints[rank] = waypoints
def _build_center_path(self, waypoints, turn_margin, curve_resolution): # barrier 索引仍指向 center_path 內 (range [0, n_pts-1]),
# 不觸及末尾收尾點,直接沿用即可
return all_waypoints, barrier_indices
def _build_center_path(self, waypoints,
formation_max_lateral,
min_inner_radius,
arc_resolution,
sharp_turn_cos_threshold):
""" """
建立帶 Bezier 曲線轉彎的中心路徑 建立以「圓弧」連接直線段的中心路徑。
在每個轉折 WP 處: 每個中間 waypoint 的處理:
1. 計算 pre_turn = WP - d_in × T 1. 計算入向 u_in、出向 u_out 的夾角 β = acos(u_in·u_out)
2. 計算 post_turn = WP + d_out × T 2. 若 cos β < sharp_turn_cos_threshold → 銳角:只插單點 (hover + 原地轉向),
3. 用二次 Bezier 曲線: B(t) = (1-t)²·pre + 2t(1-t)·WP + t²·post barrier 放在該點,讓隊伍整體停下再繼續
4. 方向從導數得到: B'(t) = 2(1-t)(WP-pre) + 2t(post-WP) 3. 否則 → 平滑弧:
R_base = formation_max_lateral + min_inner_radius
d = R_base × tan(β/2),並以相鄰段長的 45% 為上限
R_actual = d / tan(β/2)
tangent points T_in = WP - u_in·d, T_out = WP + u_out·d
arc center = T_in + R_actual·sign·n_left,其中 sign=+1 左轉/CCW,-1 右轉/CW
arc 由 theta_in 到 theta_out 以 arc_resolution 等分
path 內容: [T_in(straight,u_in), arc_samples..., T_out(straight,u_out)]
barrier 放在 T_out (轉完彎後的集合點)
Args: path 是一個 list[dict],每個 dict 至少含:
waypoints: 路徑航點 [(x, y), ...] {'seg': 'straight' | 'arc',
turn_margin: 轉彎切入距離佔相鄰段長的比例 (0~0.5) 'x': float, 'y': float,
curve_resolution: 每個彎道的 Bezier 插值點數 'dir': (dx, dy)}
'arc' 類型額外含: 'arc_center', 'arc_R', 'arc_sign', 'theta'
Returns: Returns:
[(x, y, dir_x, dir_y), ...] 中心路徑點 + 單位方向 (path, barrier_indices)
""" """
num_wps = len(waypoints) num_wps = len(waypoints)
if num_wps < 2: if num_wps < 2:
return [(waypoints[0][0], waypoints[0][1], 0.0, 1.0)] return [{
'seg': 'straight',
'x': waypoints[0][0], 'y': waypoints[0][1],
'dir': (0.0, 1.0),
}], []
# 計算每段方向和長度
segments = [] segments = []
for i in range(num_wps - 1): for i in range(num_wps - 1):
dx = waypoints[i + 1][0] - waypoints[i][0] dx = waypoints[i + 1][0] - waypoints[i][0]
dy = waypoints[i + 1][1] - waypoints[i][1] dy = waypoints[i + 1][1] - waypoints[i][1]
length = math.sqrt(dx ** 2 + dy ** 2) length = math.hypot(dx, dy)
if length < 0.01: if length < 0.01:
segments.append((0.0, 1.0, length)) segments.append(((0.0, 1.0), length))
else: else:
segments.append((dx / length, dy / length, length)) segments.append(((dx / length, dy / length), length))
R_base = formation_max_lateral + min_inner_radius
path = [] path = []
barrier_indices = []
# 第一個航點 # 起點
path.append((waypoints[0][0], waypoints[0][1], path.append({
segments[0][0], segments[0][1])) 'seg': 'straight',
'x': waypoints[0][0], 'y': waypoints[0][1],
'dir': segments[0][0],
})
# 中間航點 (轉彎處)
for i in range(1, num_wps - 1): for i in range(1, num_wps - 1):
d_in_x, d_in_y, len_in = segments[i - 1] u_in, len_in = segments[i - 1]
d_out_x, d_out_y, len_out = segments[i] u_out, len_out = segments[i]
wp = waypoints[i]
# 切入距離 T: 取相鄰兩段中較短的 × turn_margin dot = u_in[0] * u_out[0] + u_in[1] * u_out[1]
T = min(len_in, len_out) * turn_margin dot = max(-1.0, min(1.0, dot))
if T < 0.5: # 銳角:hover + 原地轉向
# 段太短,不插彎,直接加一個平均方向點 if dot < sharp_turn_cos_threshold:
avg_dx = d_in_x + d_out_x avg_dx = u_in[0] + u_out[0]
avg_dy = d_in_y + d_out_y avg_dy = u_in[1] + u_out[1]
avg_len = math.sqrt(avg_dx ** 2 + avg_dy ** 2) avg_len = math.hypot(avg_dx, avg_dy)
if avg_len > 0.01: if avg_len > 0.01:
avg_dx /= avg_len avg_dir = (avg_dx / avg_len, avg_dy / avg_len)
avg_dy /= avg_len
else: else:
avg_dx, avg_dy = d_in_x, d_in_y avg_dir = u_in
path.append((waypoints[i][0], waypoints[i][1], avg_dx, avg_dy)) path.append({
'seg': 'straight',
'x': wp[0], 'y': wp[1],
'dir': avg_dir,
})
barrier_indices.append(len(path) - 1)
continue continue
# P0 = pre_turn (弧線起始) # 平滑弧
p0_x = waypoints[i][0] - d_in_x * T beta = math.acos(dot)
p0_y = waypoints[i][1] - d_in_y * T if beta < 1e-4:
# 幾乎直線,不插弧
path.append({
'seg': 'straight',
'x': wp[0], 'y': wp[1],
'dir': u_in,
})
continue
# P1 = WP 本身 (控制點) half_tan = math.tan(beta / 2.0)
p1_x = waypoints[i][0] d_ideal = R_base * half_tan
p1_y = waypoints[i][1] d_max = 0.45 * min(len_in, len_out)
d = min(d_ideal, d_max)
R_actual = d / half_tan
# P2 = post_turn (弧線結束) T_in = (wp[0] - u_in[0] * d, wp[1] - u_in[1] * d)
p2_x = waypoints[i][0] + d_out_x * T T_out = (wp[0] + u_out[0] * d, wp[1] + u_out[1] * d)
p2_y = waypoints[i][1] + d_out_y * T
# 加入 pre_turn 點 (方向 = incoming) # +1 CCW (左轉) / -1 CW (右轉)
path.append((p0_x, p0_y, d_in_x, d_in_y)) cross = u_in[0] * u_out[1] - u_in[1] * u_out[0]
sign = 1 if cross > 0 else -1
# 加入 Bezier 插值點 # 入向左法線
for j in range(1, curve_resolution): n_left = (-u_in[1], u_in[0])
t = j / curve_resolution center = (
T_in[0] + R_actual * sign * n_left[0],
T_in[1] + R_actual * sign * n_left[1],
)
# B(t) = (1-t)²·P0 + 2t(1-t)·P1 + t²·P2 theta_in = math.atan2(T_in[1] - center[1], T_in[0] - center[0])
one_minus_t = 1.0 - t
bx = one_minus_t * one_minus_t * p0_x + \
2 * t * one_minus_t * p1_x + \
t * t * p2_x
by = one_minus_t * one_minus_t * p0_y + \
2 * t * one_minus_t * p1_y + \
t * t * p2_y
# B'(t) = 2(1-t)(P1-P0) + 2t(P2-P1) → 切線方向 # T_in (straight, 方向 u_in)
tdx = 2 * one_minus_t * (p1_x - p0_x) + 2 * t * (p2_x - p1_x) path.append({
tdy = 2 * one_minus_t * (p1_y - p0_y) + 2 * t * (p2_y - p1_y) 'seg': 'straight',
'x': T_in[0], 'y': T_in[1],
'dir': u_in,
})
# 正規化 # 弧段採樣 k=1..arc_resolution-1(不含兩端)
t_len = math.sqrt(tdx ** 2 + tdy ** 2) for k in range(1, arc_resolution):
if t_len > 0.01: t = k / arc_resolution
tdx /= t_len theta = theta_in + sign * beta * t
tdy /= t_len px = center[0] + R_actual * math.cos(theta)
else: py = center[1] + R_actual * math.sin(theta)
tdx, tdy = d_in_x, d_in_y # 切線: sign × (-sin θ, cos θ)
tx = sign * (-math.sin(theta))
ty = sign * math.cos(theta)
path.append({
'seg': 'arc',
'x': px, 'y': py,
'dir': (tx, ty),
'arc_center': center,
'arc_R': R_actual,
'arc_sign': sign,
'theta': theta,
})
path.append((bx, by, tdx, tdy)) # T_out (straight, 方向 u_out),barrier 放這
path.append({
'seg': 'straight',
'x': T_out[0], 'y': T_out[1],
'dir': u_out,
})
barrier_indices.append(len(path) - 1)
# 加入 post_turn 點 (方向 = outgoing) # 終點
path.append((p2_x, p2_y, d_out_x, d_out_y)) path.append({
'seg': 'straight',
'x': waypoints[-1][0], 'y': waypoints[-1][1],
'dir': segments[-1][0],
})
# 最後一個航點 # 後處理:為每個 sample 加上累積 polyline 弧長 s
path.append((waypoints[-1][0], waypoints[-1][1], path[0]['s'] = 0.0
segments[-1][0], segments[-1][1])) for i in range(1, len(path)):
prev = path[i - 1]
cur = path[i]
cur['s'] = prev['s'] + math.hypot(
cur['x'] - prev['x'], cur['y'] - prev['y']
)
return path return path, barrier_indices
# ------------------------------------------------------------------ 柵狀掃描 # ------------------------------------------------------------------ 柵狀掃描
@@ -474,7 +631,15 @@ class FormationPlanner:
all_waypoints[original_idx] = waypoints_list all_waypoints[original_idx] = waypoints_list
return all_waypoints # 每一條掃描線的「起點」都做一次同步:
# wp 0 = gather, wp 1 = 第一條線起點, wp 2 = 第一條線終點,
# wp 3 = 第二條線起點, wp 4 = 第二條線終點, ...
# 每條線的「起點 index」= 1, 3, 5, ... = 2*i + 1(i 從 0 開始)
# 所有 drone 的 waypoint 數量相同(num_lines 對所有 strip 都一致),
# 所以用同一份 rendezvous_indices
rendezvous_indices = [2 * i + 1 for i in range(num_lines)]
return all_waypoints, rendezvous_indices
# ================================================================================ # ================================================================================
@@ -492,12 +657,12 @@ if __name__ == "__main__":
target = (24.12400, 120.56795, 10.0) target = (24.12400, 120.56795, 10.0)
# M-Formation # M-Formation
wps, origin = planner.plan_formation_mission(drones, target, MissionType.M_FORMATION) wps, origin, rv = planner.plan_formation_mission(drones, target, MissionType.M_FORMATION)
print("M-Formation:") print("M-Formation:")
for i, wp_list in enumerate(wps): for i, wp_list in enumerate(wps):
print(f" Drone {i}: {len(wp_list)} waypoints") print(f" Drone {i}: {len(wp_list)} waypoints")
# Leader-Follower (Bezier 轉彎) # Leader-Follower (虛擬領隊 / 弧長參數化)
route = [ route = [
(24.12345, 120.56780), (24.12345, 120.56780),
(24.12370, 120.56800), (24.12370, 120.56800),
@@ -505,18 +670,18 @@ if __name__ == "__main__":
(24.12400, 120.56800), (24.12400, 120.56800),
(24.12420, 120.56790), (24.12420, 120.56790),
] ]
wps_lf, origin_lf = planner.plan_formation_mission( wps_lf, origin_lf, rv_lf = planner.plan_formation_mission(
drones, target, MissionType.LEADER_FOLLOWER, drones, target, MissionType.LEADER_FOLLOWER,
params={ params={
'route_waypoints': route, 'route_waypoints': route,
'lateral_offset': 3.0, 'lateral_offset': 3.0,
'longitudinal_spacing': 5.0, 'longitudinal_spacing': 5.0,
'turn_margin': 0.35, 'min_inner_radius': 3.0,
'curve_resolution': 8, 'arc_resolution': 8,
'altitude': 10.0 'altitude': 10.0
} }
) )
print(f"\nLeader-Follower (Bezier turns):") print(f"\nLeader-Follower (arc-length virtual leader):")
for i, wp_list in enumerate(wps_lf): for i, wp_list in enumerate(wps_lf):
print(f" Drone {i}: {len(wp_list)} waypoints") print(f" Drone {i}: {len(wp_list)} waypoints")
for j, wp in enumerate(wp_list): for j, wp in enumerate(wp_list):
@@ -529,7 +694,7 @@ if __name__ == "__main__":
(24.1240, 120.5683), (24.1240, 120.5683),
(24.1240, 120.5679) (24.1240, 120.5679)
] ]
wps2, origin2 = planner.plan_formation_mission( wps2, origin2, rv2 = planner.plan_formation_mission(
drones, target, MissionType.GRID_SWEEP, drones, target, MissionType.GRID_SWEEP,
params={'rect_corners': rect, 'line_spacing': 5.0, 'altitude': 10.0} params={'rect_corners': rect, 'line_spacing': 5.0, 'altitude': 10.0}
) )
+1 -1
View File
@@ -7,7 +7,7 @@ class OverviewTable(QTableWidget):
# 默認的資訊類型和映射 # 默認的資訊類型和映射
DEFAULT_INFO_TYPES = ["模式", "ARM", "電壓", "經度", "緯度", "高度", "XY位置", "XY速度", "地速", "航向", DEFAULT_INFO_TYPES = ["模式", "ARM", "電壓", "經度", "緯度", "高度", "XY位置", "XY速度", "地速", "航向",
"空速", "油門", "HUD ALT", "爬升率", "Roll", "Pitch", "Yaw", "丟包", "延遲"] "空速", "油門", "海拔高度", "爬升率", "Roll", "Pitch", "Yaw", "丟包", "延遲"]
DEFAULT_INFO_TYPE_MAP = { DEFAULT_INFO_TYPE_MAP = {
"mode": 0, "mode": 0,
+2 -2
View File
@@ -210,7 +210,7 @@ def main():
target_gps = (target_lat, target_lon, BASE_ALTITUDE) target_gps = (target_lat, target_lon, BASE_ALTITUDE)
print(f" 目標點: ({target_lat:.6f}, {target_lon:.6f})") print(f" 目標點: ({target_lat:.6f}, {target_lon:.6f})")
waypoints_per_drone, origin = planner.plan_formation_mission( waypoints_per_drone, origin, _ = planner.plan_formation_mission(
drone_gps_positions, target_gps, MissionType.M_FORMATION drone_gps_positions, target_gps, MissionType.M_FORMATION
) )
@@ -232,7 +232,7 @@ def main():
target_gps = (rect_center_lat, rect_center_lon, BASE_ALTITUDE) target_gps = (rect_center_lat, rect_center_lon, BASE_ALTITUDE)
print(f" 矩形中心: ({rect_center_lat:.6f}, {rect_center_lon:.6f})") print(f" 矩形中心: ({rect_center_lat:.6f}, {rect_center_lon:.6f})")
waypoints_per_drone, origin = planner.plan_formation_mission( waypoints_per_drone, origin, _ = planner.plan_formation_mission(
drone_gps_positions, target_gps, MissionType.GRID_SWEEP, drone_gps_positions, target_gps, MissionType.GRID_SWEEP,
params={ params={
'rect_corners': rect_corners, 'rect_corners': rect_corners,
+3 -3
View File
@@ -365,7 +365,7 @@ def visualize_standalone():
fig.suptitle('Mission Planner Verification (standalone)', fontsize=14, fontweight='bold') fig.suptitle('Mission Planner Verification (standalone)', fontsize=14, fontweight='bold')
# M-Formation # M-Formation
wps_m, origin_m = planner.plan_formation_mission(drones, target, MissionType.M_FORMATION) wps_m, origin_m, _ = planner.plan_formation_mission(drones, target, MissionType.M_FORMATION)
conv_m = CoordinateConverter(origin_m[0], origin_m[1], 0) conv_m = CoordinateConverter(origin_m[0], origin_m[1], 0)
data_m = { data_m = {
'drone_ids': [f's0_{i + 1}' for i in range(len(drones))], 'drone_ids': [f's0_{i + 1}' for i in range(len(drones))],
@@ -380,7 +380,7 @@ def visualize_standalone():
# Grid Sweep 5m # Grid Sweep 5m
target_gs = (sum(c[0] for c in rect_corners) / 4, target_gs = (sum(c[0] for c in rect_corners) / 4,
sum(c[1] for c in rect_corners) / 4, 10.0) sum(c[1] for c in rect_corners) / 4, 10.0)
wps_g, origin_g = planner.plan_formation_mission( wps_g, origin_g, _ = planner.plan_formation_mission(
drones, target_gs, MissionType.GRID_SWEEP, drones, target_gs, MissionType.GRID_SWEEP,
params={'rect_corners': rect_corners, 'line_spacing': 5.0, 'altitude': 10.0} params={'rect_corners': rect_corners, 'line_spacing': 5.0, 'altitude': 10.0}
) )
@@ -403,7 +403,7 @@ def visualize_standalone():
(24.12410, 120.56800), (24.12410, 120.56800),
(24.12420, 120.56790), (24.12420, 120.56790),
] ]
wps_lf, origin_lf = planner.plan_formation_mission( wps_lf, origin_lf, _ = planner.plan_formation_mission(
drones, target, MissionType.LEADER_FOLLOWER, drones, target, MissionType.LEADER_FOLLOWER,
params={'route_waypoints': route, 'lateral_offset': 3.0, params={'route_waypoints': route, 'lateral_offset': 3.0,
'longitudinal_spacing': 5.0, 'altitude': 10.0} 'longitudinal_spacing': 5.0, 'altitude': 10.0}
@@ -29,7 +29,7 @@ from .utils import acquireSerial, acquirePort
from .utils.acquirePort import find_available_port from .utils.acquirePort import find_available_port
logger = setup_logger(os.path.basename(__file__)) logger = setup_logger(os.path.basename(__file__))
PROJECT_VER = "v0.61" PROJECT_VER = "v0.70"
class PanelState: class PanelState:
def __init__(self): def __init__(self):
@@ -45,7 +45,7 @@ class PanelState:
# 關於創建通道時的暫存資訊 # 關於創建通道時的暫存資訊
self.udp_info_temp = {"IP": "127.0.0.1", "Port": "", "Direction": ""} # 暫存 UDP 設定資訊 self.udp_info_temp = {"IP": "127.0.0.1", "Port": "", "Direction": ""} # 暫存 UDP 設定資訊
self.serial_info_temp = {"Port": "", "Baud": 115200, "CommunicationType": "", "Go2Middleware": False} # 暫存 Serial 設定資訊 self.serial_info_temp = {"Port": "", "Baud": 115200, "CommunicationType": "", "Go2Middleware": True} # 暫存 Serial 設定資訊
# 關於顯示通道資訊 # 關於顯示通道資訊
self.socket_info_single = { self.socket_info_single = {
@@ -426,7 +426,7 @@ class ControlPanel:
menu_stack.pop() menu_stack.pop()
idx_stack.pop() idx_stack.pop()
elif selected.action == "SET_SERIAL_COMM_TELEMETRY": elif selected.action == "SET_SERIAL_COMM_TELEMETRY":
state.serial_info_temp["CommunicationType"] = "XBee(AT-AT)" state.serial_info_temp["CommunicationType"] = "TELEMETRY"
menu_stack.pop() menu_stack.pop()
idx_stack.pop() idx_stack.pop()
@@ -767,7 +767,7 @@ class ControlPanel:
port_menu = MenuNode(f"{port}", children=[ port_menu = MenuNode(f"{port}", children=[
MenuNode("Set Comm Type", "設定通訊形態", "SET_SERIAL_COMM", children=[ MenuNode("Set Comm Type", "設定通訊形態", "SET_SERIAL_COMM", children=[
MenuNode("XBee(API-AT)", "XBee 模式", "SET_SERIAL_COMM_XBEE"), MenuNode("XBee(API-AT)", "XBee 模式", "SET_SERIAL_COMM_XBEE"),
# MenuNode("Telemetry", "數傳模式", "SET_SERIAL_COMM_TELEMETRY"), MenuNode("Telemetry", "數傳模式", "SET_SERIAL_COMM_TELEMETRY"),
]), ]),
MenuNode("Set Baud", "設定 Baud", "TEXT_BAUD_SERIAL"), MenuNode("Set Baud", "設定 Baud", "TEXT_BAUD_SERIAL"),
MenuNode("Link to MW", "直接建立 Middleware UDP", "LINK_SERIAL_TO_MIDDLEWARE_UDP", children=[ MenuNode("Link to MW", "直接建立 Middleware UDP", "LINK_SERIAL_TO_MIDDLEWARE_UDP", children=[
@@ -1058,11 +1058,15 @@ class ControlPanel:
# MAVLink 訊息名稱對應(縮寫版本) # MAVLink 訊息名稱對應(縮寫版本)
MSG_NAMES = { MSG_NAMES = {
0: "HB", 1: "SYS_ST", 24: "GPS_RAW", 27: "RAW_IMU", 0: "HB", 1: "S_STAT", 2: "S_TIME", 24: "GPS_RAW", 27: "RAW_IMU",
30: "ATT", 32: "LOC_POS", 33: "GLB_POS", 62: "NAV_CTL", 30: "ATT", 32: "LOC_POS", 33: "GLB_POS", 62: "NAV_CTL",
74: "VFR_HUD", 147: "BATT_ST" 74: "VFR_HUD", 147: "BATT_ST", 136: "TERRAIN", 241: "VIBRAT",
125: "POW_STA",
} }
# ardupilot mega
# 178: "AHRS2", 163: "AHRS", 193: "EKF_STAT",
# 顯示前 12 個最常見的訊息類型(兩列各 6 個) # 顯示前 12 個最常見的訊息類型(兩列各 6 個)
msg_items = list(msg_counts.items())[:12] msg_items = list(msg_counts.items())[:12]
line = 13 line = 13
@@ -1134,6 +1138,7 @@ class Orchestrator:
self.bridge = mo.mavlink_bridge() self.bridge = mo.mavlink_bridge()
# === 3) serial_manager 部分的準備 === # === 3) serial_manager 部分的準備 ===
sm.rx_module_ack.clear()
self.plumber = sm.serial_manager() self.plumber = sm.serial_manager()
# === 4) ros 部分的準備 === # === 4) ros 部分的準備 ===
@@ -1243,7 +1248,7 @@ class Orchestrator:
if serial_obj: if serial_obj:
self.panelState.serial_info_single["serial_port"] = serial_obj.serial_port self.panelState.serial_info_single["serial_port"] = serial_obj.serial_port
self.panelState.serial_info_single["baudrate"] = serial_obj.baudrate self.panelState.serial_info_single["baudrate"] = serial_obj.baudrate
self.panelState.serial_info_single["receiver_type"] = serial_obj.receiver_type.name self.panelState.serial_info_single["receiver_type"] = serial_obj.serial_mode.name
self.panelState.serial_info_single["target_port"] = serial_obj.target_port self.panelState.serial_info_single["target_port"] = serial_obj.target_port
self.panelState.serial_info_single["InfoReady"] = True # 標記資訊已準備好 self.panelState.serial_info_single["InfoReady"] = True # 標記資訊已準備好
elif action == "REMOVE_LINKED_SERIAL": elif action == "REMOVE_LINKED_SERIAL":
@@ -1516,8 +1521,8 @@ class Orchestrator:
# 定義通訊類型映射表 # 定義通訊類型映射表
COMM_TYPE_MAP = { COMM_TYPE_MAP = {
"XBee(API-AT)": sm.SerialReceiverType.XBEEAPI2AT, "XBee(API-AT)": sm.SerialMode.XBEEAPI2AT,
# "XBee(AT-AT)": sm.SerialReceiverType.TELEMETRY, # TODO: 之後再弄 "TELEMETRY": sm.SerialMode.STRAIGHT,
# 新增區 # 新增區
} }
@@ -1541,7 +1546,7 @@ class Orchestrator:
serial_port=self.panelState.serial_info_temp['Port'], serial_port=self.panelState.serial_info_temp['Port'],
baudrate=self.panelState.serial_info_temp['Baud'], baudrate=self.panelState.serial_info_temp['Baud'],
target_port=udp_port_tmp, target_port=udp_port_tmp,
receiver_type=comm_type_tmp, serial_mode=comm_type_tmp,
) )
if not ret: if not ret:
@@ -1572,7 +1577,7 @@ def main():
if mvv.MODULE_VER != "1.00": if mvv.MODULE_VER != "1.00":
print("Module Version Error! : mavlinkVehicleView") print("Module Version Error! : mavlinkVehicleView")
version_check = False version_check = False
if sm.MODULE_VER != "0.60": if sm.MODULE_VER != "0.80":
print("Module Version Error! : serialManager") print("Module Version Error! : serialManager")
version_check = False version_check = False
if version_check == False: if version_check == False:
@@ -230,6 +230,7 @@ class mavlink_bridge:
'vx': msg.vx, 'vy': msg.vy, 'vz': msg.vz 'vx': msg.vx, 'vy': msg.vy, 'vz': msg.vz
} }
def _handle_global_position(self, vehicle, component, msg, timestamp): def _handle_global_position(self, vehicle, component, msg, timestamp):
"""處理 GLOBAL_POSITION_INT 訊息 (msg_id: 33)""" """處理 GLOBAL_POSITION_INT 訊息 (msg_id: 33)"""
component.status.position.latitude = msg.lat / 1e7 # 轉換為度 component.status.position.latitude = msg.lat / 1e7 # 轉換為度
@@ -210,7 +210,9 @@ class VehicleStatusPublisher(Node):
if pos.latitude is None or pos.longitude is None: if pos.latitude is None or pos.longitude is None:
return return
publisher = self._get_or_create_publisher(sysid, 'position', sensor_msgs.msg.NavSatFix) publisher = self._get_or_create_publisher(
sysid, 'position', sensor_msgs.msg.NavSatFix
)
# 檢查是否有訂閱者 # 檢查是否有訂閱者
if publisher.get_subscription_count() == 0: if publisher.get_subscription_count() == 0:
@@ -243,7 +245,7 @@ class VehicleStatusPublisher(Node):
publisher = self._get_or_create_publisher( publisher = self._get_or_create_publisher(
sysid, 'position_ned', nav_msgs.msg.Odometry sysid, 'position_ned', nav_msgs.msg.Odometry
) )
if publisher.get_subscription_count() == 0: if publisher.get_subscription_count() == 0:
return return
@@ -16,19 +16,22 @@ import threading
import struct import struct
from enum import Enum, auto from enum import Enum, auto
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass
# # XBee 模組 # # XBee 模組
# from xbee.frame import APIFrame # from xbee.frame import APIFrame
# 自定義的 import # 自定義的 import
from .utils import setup_logger from .utils import RingBuffer, setup_logger
# ====================== 分割線 ===================== # ====================== 分割線 =====================
logger = setup_logger(os.path.basename(__file__)) logger = setup_logger(os.path.basename(__file__))
MODULE_VER = "0.60" MODULE_VER = "0.80"
# ====================== 分割線 ===================== rx_module_ack = RingBuffer(capacity=64, buffer_id=253)
# ====================== State DEFINITION =====================
# 定義 serial 連線的模式 # 定義 serial 連線的模式
class SerialMode(Enum): class SerialMode(Enum):
@@ -38,7 +41,29 @@ class SerialMode(Enum):
NOT_USE = auto() # 不使用 NOT_USE = auto() # 不使用
# ====================== Frame Processor 基類與實現 ===================== # ====================== AT Frame Data Classes =====================
@dataclass
class ATRequest:
"""送往 dongle 的 AT 指令"""
command: bytes # 例如 b'DB'
parameter: bytes # 寫入用指令的參數,讀取型通常為空
frame_id: int # XBee frame id;0x00 表示不要求回應
@dataclass
class ATResponse:
"""dongle 回傳的 AT Response (0x88)"""
frame_id: int
command: bytes
status: int
data: bytes
@property
def is_ok(self) -> bool:
return self.status == 0x00
# ====================== Frame Processor Base and Implementation =====================
class FrameProcessor(ABC): class FrameProcessor(ABC):
"""協議處理器基類""" """協議處理器基類"""
@@ -47,10 +72,10 @@ class FrameProcessor(ABC):
self.buffer = bytearray() self.buffer = bytearray()
@abstractmethod @abstractmethod
def process_incoming(self, data: bytes): def process_incoming(self, data: bytes) -> bytes:
""" """
處理接收到的數據 處理接收到的數據
返回:已完整解析的 payload 列表 返回:已完整解析的 payload 列表 後續要丟到 UDP 去
""" """
pass pass
@@ -66,7 +91,7 @@ class FrameProcessor(ABC):
class RawFrameProcessor(FrameProcessor): class RawFrameProcessor(FrameProcessor):
"""原始數據直通處理器""" """原始數據直通處理器"""
def process_incoming(self, data: bytes): def process_incoming(self, data: bytes) -> bytes:
"""直接返回原始數據,不進行緩衝""" """直接返回原始數據,不進行緩衝"""
return [data] if data else [] return [data] if data else []
@@ -76,110 +101,108 @@ class RawFrameProcessor(FrameProcessor):
class XBeeFrameProcessor(FrameProcessor): class XBeeFrameProcessor(FrameProcessor):
"""XBee API 協議處理器""" """
XBee API 協議處理器
def process_incoming(self, data: bytes): 職責:
- XBee API frame 的拆幀 / 組幀
- 0x90 (RX Packet) -> 解出 payload 回傳
- 0x88 (AT Response) -> 轉交 at_handler 處理(若有注入)
- 0x8B (TX Status) -> 目前忽略
- 其他 frame type -> 記 warning 忽略
若未來要做變化型 XBee(例如 API2 escape mode、不同 addressing),
繼承此類並覆寫 _encapsulate / _decapsulate / _try_extract_frame 即可。
"""
# XBee API frame type
FRAME_TYPE_TX_REQUEST = 0x10
FRAME_TYPE_AT_RESPONSE = 0x88
FRAME_TYPE_TX_STATUS = 0x8B
FRAME_TYPE_RX_PACKET = 0x90
def __init__(self, at_handler: "ATCommandHandler" = None):
super().__init__()
self.at_handler = at_handler
# ---- 對外契約 ----
def process_incoming(self, data: bytes) -> bytes:
"""處理 XBee API 幀並提取 payload""" """處理 XBee API 幀並提取 payload"""
self.buffer.extend(data) self.buffer.extend(data)
payloads = [] payloads = []
while len(self.buffer) >= 3: while True:
# 尋找幀頭 frame = self._try_extract_frame()
if self.buffer[0] != 0x7E: if frame is None:
self.buffer.pop(0)
continue
# 讀取 payload 長度
length = (self.buffer[1] << 8) | self.buffer[2]
full_length = 3 + length + 1 # 起始符(1) + 長度(2) + payload + 校驗和(1)
# 等待完整幀
if len(self.buffer) < full_length:
break break
payload = self._dispatch_frame(frame)
# 提取完整 frame 並從緩衝區移除 if payload:
frame = bytes(self.buffer[:full_length]) payloads.append(payload)
del self.buffer[:full_length]
# 判斷 frame 類型並處理
frame_type = frame[3]
if frame_type == 0x90: # RX Packet
payload = XBeeFrameHandler.decapsulate_data(frame)
if payload:
payloads.append(payload)
elif frame_type == 0x88: # AT Response
# 可以在這裡處理 AT 指令回應
# response = XBeeFrameHandler.parse_at_command_response(frame)
# 目前忽略
pass
elif frame_type == 0x8B: # Transmit Status
# 傳輸狀態,目前忽略
pass
else:
logger.warning(f"Unknown XBee frame type: 0x{frame_type:02X}")
return payloads return payloads
def process_outgoing(self, data: bytes) -> bytes: def process_outgoing(self, data: bytes) -> bytes:
"""將數據封裝為 XBee API 傳輸幀""" """將數據封裝為 XBee API 傳輸幀"""
return XBeeFrameProcessor.encapsulate_data(data) return self._encapsulate(data)
# ---- 內部:拆幀與分派 ----
def _try_extract_frame(self) -> bytes:
"""
從 buffer 嘗試抓一個完整 frame
- 對齊幀頭 0x7E
- 長度不足時等待下次進 buffer
- 成功則從 buffer 切掉並回傳整個 frame bytes
"""
while len(self.buffer) >= 3:
if self.buffer[0] != 0x7E:
self.buffer.pop(0)
continue
# ====================== XBee Frame Handler ===================== length = (self.buffer[1] << 8) | self.buffer[2]
full_length = 3 + length + 1 # 起始符(1) + 長度(2) + payload + 校驗和(1)
class XBeeFrameHandler: if len(self.buffer) < full_length:
"""XBee API Frame 處理器""" return None
@staticmethod # TODO: 驗證 checksum:(sum(frame[3:]) & 0xFF) 應為 0xFF;不通過則丟棄此 frame 並從 buffer pop 1 byte 再重新對齊
def parse_at_command_response(frame: bytes) -> dict:
"""解析 AT Command Response (0x88)"""
if len(frame) < 8:
return None
frame_type = frame[3] frame = bytes(self.buffer[:full_length])
if frame_type != 0x88: del self.buffer[:full_length]
return None return frame
frame_id = frame[4]
at_command = frame[5:7]
status = frame[7]
data = frame[8:] if len(frame) > 8 else b''
return {
'frame_id': frame_id,
'command': at_command,
'status': status,
'data': data,
'is_ok': status == 0x00
}
@staticmethod
def parse_receive_packet(frame: bytes) -> dict:
"""解析 RX Packet (0x90) - 未來擴展用"""
# if len(frame) < 15 or frame[3] != 0x90:
# return None
# return {
# 'source_addr': frame[4:12],
# 'reserved': frame[12:14],
# 'options': frame[14],
# 'data': frame[15:-1]
# }
pass
return None return None
@staticmethod def _dispatch_frame(self, frame: bytes) -> bytes:
def encapsulate_data(data: bytes, dest_addr64: bytes = b'\x00\x00\x00\x00\x00\x00\xFF\xFF', frame_id = 0x01) -> bytes: """根據 frame type 分派;若是 RX payload 回傳 bytes,其餘回傳 None"""
""" frame_type = frame[3]
將數據封裝為 XBee API 傳輸幀
使用 XBee API 格式封裝數據: if frame_type == self.FRAME_TYPE_RX_PACKET:
- 傳輸請求幀 (0x10) return self._decapsulate(frame)
if frame_type == self.FRAME_TYPE_AT_RESPONSE:
if self.at_handler is not None:
self.at_handler.handle_frame(frame)
return None
if frame_type == self.FRAME_TYPE_TX_STATUS:
return None
logger.warning(f"Unknown XBee frame type: 0x{frame_type:02X}")
return None
# ---- 內部:編解碼 ----
@staticmethod
def _encapsulate(
data: bytes,
dest_addr64: bytes = b'\x00\x00\x00\x00\x00\x00\xFF\xFF',
frame_id: int = 0x01,
) -> bytes:
"""
將 payload 包成 XBee TX Request (0x10)
- 使用廣播地址 - 使用廣播地址
- 添加適當的頭部和校驗和 - 添加適當的頭部和校驗和
""" """
frame_type = 0x10 frame_type = XBeeFrameProcessor.FRAME_TYPE_TX_REQUEST
dest_addr16 = b'\xFF\xFE' dest_addr16 = b'\xFF\xFE'
broadcast_radius = 0x00 broadcast_radius = 0x00
options = 0x00 options = 0x00
@@ -191,20 +214,33 @@ class XBeeFrameHandler:
return b'\x7E' + struct.pack(">H", len(frame)) + frame + struct.pack("B", checksum) return b'\x7E' + struct.pack(">H", len(frame)) + frame + struct.pack("B", checksum)
@staticmethod @staticmethod
def decapsulate_data(data: bytes): def _decapsulate(frame: bytes) -> bytes:
"""從 RX Packet (0x90) 取出 payload"""
# 獲取數據長度 (不包括校驗和) length = (frame[1] << 8) | frame[2]
length = (data[1] << 8) | data[2]
rf_data_start = 3 + 12 rf_data_start = 3 + 12
return data[rf_data_start:3 + length] return frame[rf_data_start:3 + length]
# ====================== Dongle Command Handler =====================
class ATCommandHandler: class ATCommandHandler:
"""AT 指令回應處理器""" """
XBee AT 指令處理器
職責:
- 組 AT Command Request frame (0x08) 並透過注入的 writer 送出
- 解析 AT Response frame (0x88)
- 依 AT 指令分派到對應的 handler
writer 由 SerialHandler 在連線建立後 (connection_made) 注入,
型別為 Callable[[bytes], None](通常就是 serial transport.write)。
"""
FRAME_TYPE_AT_COMMAND = 0x08
def __init__(self, serial_port: str): def __init__(self, serial_port: str):
self.serial_port = serial_port self.serial_port = serial_port
self.writer = None
self.handlers = { self.handlers = {
b'DB': self._handle_rssi, b'DB': self._handle_rssi,
b'SH': self._handle_serial_high, b'SH': self._handle_serial_high,
@@ -212,25 +248,96 @@ class ATCommandHandler:
# 可擴展其他 AT 指令 # 可擴展其他 AT 指令
} }
def handle_response(self, response: dict): # ---- 發送端 ----
"""根據 AT 指令類型分派處理""" def set_writer(self, writer):
if not response or not response['is_ok']: """由 SerialHandler 注入實際寫入 serial 的 callable"""
if response: self.writer = writer
logger.warning(f"[{self.serial_port}] AT {response['command'].decode()} 失敗,狀態碼: {response['status']}")
def send_command(self, request: ATRequest):
"""發送一筆 AT 指令給 dongle"""
if self.writer is None:
logger.warning(
f"[{self.serial_port}] AT writer 尚未就緒,指令 {request.command!r} 丟棄"
)
return return
command = response['command'] self.writer(self._build_at_request(request))
handler = self.handlers.get(command) # logger.debug(
# f"[{self.serial_port}] send AT {request.command.decode(errors='replace')} "
# f"(frame_id=0x{request.frame_id:02X})"
# ) # dev
@staticmethod
def _build_at_request(request: ATRequest) -> bytes:
"""將 ATRequest 組成 XBee API AT Command Request frame (0x08) bytes"""
frame_type = ATCommandHandler.FRAME_TYPE_AT_COMMAND
frame = struct.pack(">B", frame_type) + struct.pack(">B", request.frame_id)
frame += request.command + request.parameter
checksum = 0xFF - (sum(frame) & 0xFF)
return b'\x7E' + struct.pack(">H", len(frame)) + frame + struct.pack("B", checksum)
# ---- 接收端 ----
def handle_frame(self, frame: bytes) -> None:
"""
接收一整個 AT Response frame:
1. 解析成 ATResponse
2. 推進 rx_module_ack 供其他模組消費
3. 本地 dispatch 給對應的 _handle_xxx
"""
parsed = self._parse(frame)
if parsed is None:
return
if not rx_module_ack.put(parsed):
logger.warning(
f"[{self.serial_port}] rx_module_ack overflow, drop {parsed.command!r}"
)
self._dispatch(parsed)
@staticmethod
def _parse(frame: bytes) -> ATResponse:
"""解析 AT Command Response (0x88);不符格式回傳 None"""
if len(frame) < 8:
return None
if frame[3] != 0x88:
return None
return ATResponse(
frame_id=frame[4],
command=frame[5:7],
status=frame[7],
data=frame[8:] if len(frame) > 8 else b'',
)
def _dispatch(self, response: ATResponse) -> None:
"""根據 AT 指令類型分派處理"""
# print(f"[{self.serial_port}] AT Response: {response}") # dev
if not response.is_ok:
logger.warning(
f"[{self.serial_port}] AT {response.command.decode()} "
f"失敗,狀態碼: {response.status}"
)
return
handler = self.handlers.get(response.command)
if handler: if handler:
handler(response['data']) handler(response.data)
else: else:
logger.debug(f"[{self.serial_port}] 未處理的 AT 指令: {command.decode()}") logger.debug(
f"[{self.serial_port}] 未處理的 AT 指令: "
f"{response.command.decode()}"
)
def _handle_rssi(self, data: bytes): def _handle_rssi(self, data: bytes):
"""處理 DB (RSSI) 回應""" """處理 DB (RSSI) 回應:單 byte 無號值,單位 dBm"""
# 未來可實現 RSSI 處理邏輯
pass pass
# if data:
# print(f"[{self.serial_port}] RSSI = -{data[0]} dBm") # dev
# logger.debug(f"[{self.serial_port}] RSSI = -{data[0]} dBm") # dev
def _handle_serial_high(self, data: bytes): def _handle_serial_high(self, data: bytes):
"""處理 SH (Serial Number High)""" """處理 SH (Serial Number High)"""
@@ -241,8 +348,7 @@ class ATCommandHandler:
pass pass
# ====================== Serial Handler ===================== # ================ Serial UDP Socket Object ==============
class SerialHandler(asyncio.Protocol): class SerialHandler(asyncio.Protocol):
"""asyncio.Protocol 用於處理 Serial 收發""" """asyncio.Protocol 用於處理 Serial 收發"""
@@ -252,28 +358,29 @@ class SerialHandler(asyncio.Protocol):
self.serial_mode = serial_mode self.serial_mode = serial_mode
self.transport = None # Serial 自己的傳輸物件 self.transport = None # Serial 自己的傳輸物件
# 根據模式創建對應的 processor # 根據模式建立 processor(需要 AT handler 時一併在工廠內建好注入)
self.processor = self._create_processor(serial_mode) self.processor = self._create_processor()
# AT 指令處理器(僅 XBee 模式使用) def _create_processor(self) -> FrameProcessor:
if serial_mode == SerialMode.XBEEAPI2AT: """工廠方法:根據 self.serial_mode 建立對應的 processor,必要時一併注入 AT handler"""
self.at_handler = ATCommandHandler(serial_port_str) if self.serial_mode == SerialMode.STRAIGHT:
else: return RawFrameProcessor()
self.at_handler = None
def _create_processor(self, serial_mode: SerialMode) -> FrameProcessor: if self.serial_mode == SerialMode.XBEEAPI2AT:
"""工廠方法:根據模式創建處理器""" at_handler = ATCommandHandler(self.serial_port_str)
if serial_mode == SerialMode.STRAIGHT: return XBeeFrameProcessor(at_handler=at_handler)
return RawFrameProcessor()
elif serial_mode == SerialMode.XBEEAPI2AT: logger.warning(f"Unknown serial mode: {self.serial_mode}, using Raw")
return XBeeFrameProcessor() return RawFrameProcessor()
else:
logger.warning(f"Unknown serial mode: {serial_mode}, using Raw")
return RawFrameProcessor()
def connection_made(self, transport): def connection_made(self, transport):
"""連接建立時的回調""" """連接建立時的回調"""
self.transport = transport self.transport = transport
# XBee 模式下,將 transport.write 注入給 AT handler,讓它能送指令
if self.serial_mode == SerialMode.XBEEAPI2AT:
self.processor.at_handler.set_writer(self.transport.write)
if hasattr(self.udp_handler, 'set_serial_handler'): if hasattr(self.udp_handler, 'set_serial_handler'):
self.udp_handler.set_serial_handler(self) self.udp_handler.set_serial_handler(self)
logger.debug(f"Serial port {self.serial_port_str} connected") logger.debug(f"Serial port {self.serial_port_str} connected")
@@ -291,8 +398,6 @@ class SerialHandler(asyncio.Protocol):
) )
# ====================== UDP Handler =====================
class UDPHandler(asyncio.DatagramProtocol): class UDPHandler(asyncio.DatagramProtocol):
"""asyncio.DatagramProtocol 用於處理 UDP 收發""" """asyncio.DatagramProtocol 用於處理 UDP 收發"""
@@ -561,6 +666,33 @@ class serial_manager:
ret[key] = obj.serial_port ret[key] = obj.serial_port
return ret return ret
def send_at_command(self, serial_id, request: ATRequest) -> bool:
"""
對指定 serial_id 的 XBee dongle 發送一筆 AT 指令(thread-safe)
- serial_id: create_serial_link 取得的編號
- request: ATRequest 物件,攜帶 command / parameter / frame_id
回傳是否成功排進事件圈
"""
if not self.running or not self.loop:
logger.error("Event loop not running, cannot send AT command")
return False
if serial_id not in self.serial_objects:
logger.error(f"Serial object {serial_id} not found")
return False
serial_obj = self.serial_objects[serial_id]
if serial_obj.serial_mode != SerialMode.XBEEAPI2AT:
logger.error(
f"Serial {serial_id} mode is {serial_obj.serial_mode.name}, "
f"AT command only supported in XBEEAPI2AT mode"
)
return False
at_handler = serial_obj.serial_handler.processor.at_handler
self.loop.call_soon_threadsafe(at_handler.send_command, request)
return True
@staticmethod @staticmethod
def check_serial_port(serial_port, baudrate): def check_serial_port(serial_port, baudrate):
"""檢查串口是否存在與可用""" """檢查串口是否存在與可用"""
@@ -598,20 +730,42 @@ if __name__ == '__main__':
sm = serial_manager() sm = serial_manager()
sm.start() sm.start()
SERIAL_PORT = '/dev/ttyUSB0' # 手動指定
SERIAL_BAUDRATE = 115200
UDP_REMOTE_PORT = 14571
sm.create_serial_link(SERIAL_PORT, SERIAL_BAUDRATE, UDP_REMOTE_PORT, SerialMode.XBEEAPI2AT)
# SERIAL_PORT = '/dev/ttyACM0' # 手動指定 # SERIAL_PORT = '/dev/ttyACM0' # 手動指定
# SERIAL_BAUDRATE = 115200 # SERIAL_BAUDRATE = 115200
# UDP_REMOTE_PORT = 14571 # UDP_REMOTE_PORT = 14571
# sm.create_serial_link(SERIAL_PORT, SERIAL_BAUDRATE, UDP_REMOTE_PORT, SerialMode.STRAIGHT) # sm.create_serial_link(SERIAL_PORT, SERIAL_BAUDRATE, UDP_REMOTE_PORT, SerialMode.STRAIGHT)
SERIAL_PORT = '/dev/ttyUSB0' # 手動指定
SERIAL_BAUDRATE = 115200
UDP_REMOTE_PORT = 14561
sm.create_serial_link(SERIAL_PORT, SERIAL_BAUDRATE, UDP_REMOTE_PORT, SerialMode.XBEEAPI2AT)
linked_serial = sm.get_serial_link() linked_serial = sm.get_serial_link()
print(linked_serial) print(linked_serial)
time.sleep(60)
# 等 connection_made 完成 writer 注入,再發一筆 AT 指令測試
time.sleep(5)
rssi_request = ATRequest(command=b'DB', parameter=b'', frame_id=0x52)
for i in range(60):
sm.send_at_command(1, rssi_request)
time.sleep(1)
sm.remove_serial_link(1) sm.remove_serial_link(1)
time.sleep(3) time.sleep(3)
sm.shutdown() sm.shutdown()
'''
================= 改版記錄 ============================
2026.4.20
1. XBeeFrameHandler 結構移除
2. XBeeFrameProcessor 新增 _encapsulate, _decapsulate 編碼解碼 xbee 封包的功能 (原來在 XBeeFrameHandler 中)
3. XBeeFrameProcessor 新增 _try_extract_frame 處理被可能截斷的 UART 封包
4. XBeeFrameProcessor 新增 _dispatch_frame 分配封包到 UDP 或者 Dongle Command Handler
5. ATCommandHandler 新增 _parse 去拆解 0x88 AT Command Response
6. ATCommandHandler 新增 _dispatch 把拆解的結果 分配到 _handle_XXX
7. ATCommandHandler 新增各項 _handle_XXX (未實作)
'''
+139 -61
View File
@@ -29,10 +29,14 @@ class CommandLongResult:
class CommandLongClient(Node): class CommandLongClient(Node):
"""ROS2 client : 對同一個 send_command_long service 發送各種 MAV_CMD_*。""" """ROS2 client : 對同一個 send_command_long service 發送各種 MAV_CMD_*。"""
def __init__(self, service_name: str = DEFAULT_SERVICE_NAME) -> None: def __init__(self, service_name: str = DEFAULT_SERVICE_NAME, node_name: str = None) -> None:
if not rclpy.ok(): if not rclpy.ok():
rclpy.init(args=None) rclpy.init(args=None)
super().__init__("fc_command_long_client") # 使用提供的 node_name,或使用帶時間戳的預設名稱以避免名稱衝突
if node_name is None:
import time
node_name = f"fc_command_long_client_{int(time.time() * 1000) % 100000}"
super().__init__(node_name)
self._client = self.create_client(MavCommandLong, service_name) self._client = self.create_client(MavCommandLong, service_name)
def wait_for_service(self, timeout_sec: float = 3.0) -> bool: def wait_for_service(self, timeout_sec: float = 3.0) -> bool:
@@ -192,6 +196,83 @@ class CommandLongClient(Node):
# 這些方法在 ThreadPoolExecutor 中運行同步版本,以避免阻塞事件循環 # 這些方法在 ThreadPoolExecutor 中運行同步版本,以避免阻塞事件循環
# ============================================================================ # ============================================================================
# ============================================================================
# 【重新實現】正確的非阻塞 async 方法(不使用 ThreadPoolExecutor)
# 使用 asyncio 的 polling 機制,避免在線程中調用 spin_until_future_complete
# ============================================================================
async def _send_command_long_async(
self,
*,
target_sysid: int,
target_compid: int,
command: int,
confirmation: int,
param1: float,
param2: float,
param3: float,
param4: float,
param5: float,
param6: float,
param7: float,
timeout_sec: float,
) -> CommandLongResult:
"""非阻塞的 async 版本 - 使用 asyncio polling 而非 spin"""
req = MavCommandLong.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.command = command
req.confirmation = confirmation
req.param1 = param1
req.param2 = param2
req.param3 = param3
req.param4 = param4
req.param5 = param5
req.param6 = param6
req.param7 = param7
req.timeout_sec = float(timeout_sec)
# 發送異步調用
future = self._client.call_async(req)
# 使用 asyncio.sleep 進行 polling,而非 spin_until_future_complete
# 使用 10ms 輪詢間隔以匹配 GUI 執行器的 spin_once() 頻率
timeout = timeout_sec + 1.0
elapsed = 0.0
poll_interval = 0.01 # 10ms - 與 GUI timer 頻率一致
while elapsed < timeout:
if future.done():
try:
response = future.result()
if response is None:
return CommandLongResult(
success=False,
message="Service returned None.",
ack_result=-1,
)
return CommandLongResult(
success=response.success,
message=response.message,
ack_result=response.ack_result,
)
except Exception as e:
return CommandLongResult(
success=False,
message=f"Error getting result: {e}",
ack_result=-1,
)
# 讓出控制權給事件循環,允許 GUI executor 執行
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return CommandLongResult(
success=False,
message=f"Service timeout after {timeout}s.",
ack_result=-1,
)
async def change_mode_async( async def change_mode_async(
self, self,
*, *,
@@ -202,20 +283,20 @@ class CommandLongClient(Node):
confirmation: int = 0, confirmation: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC, timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult: ) -> CommandLongResult:
"""非阻塞 async 版本的 change_mode(在 ThreadPoolExecutor 中運行)""" """非阻塞 async 版本的 change_mode"""
from concurrent.futures import ThreadPoolExecutor return await self._send_command_long_async(
loop = asyncio.get_event_loop() target_sysid=target_sysid,
executor = ThreadPoolExecutor(max_workers=1) target_compid=target_compid,
return await loop.run_in_executor( command=COMMAND_DO_SET_MODE,
executor, confirmation=confirmation,
lambda: self.change_mode( param1=float(base_mode),
target_sysid=target_sysid, param2=float(custom_mode),
custom_mode=custom_mode, param3=0.0,
target_compid=target_compid, param4=0.0,
base_mode=base_mode, param5=0.0,
confirmation=confirmation, param6=0.0,
timeout_sec=timeout_sec, param7=0.0,
) timeout_sec=timeout_sec,
) )
async def arm_disarm_async( async def arm_disarm_async(
@@ -228,20 +309,20 @@ class CommandLongClient(Node):
param2: float = 0.0, param2: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC, timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult: ) -> CommandLongResult:
"""非阻塞 async 版本的 arm_disarm(在 ThreadPoolExecutor 中運行)""" """非阻塞 async 版本的 arm_disarm"""
from concurrent.futures import ThreadPoolExecutor return await self._send_command_long_async(
loop = asyncio.get_event_loop() target_sysid=target_sysid,
executor = ThreadPoolExecutor(max_workers=1) target_compid=target_compid,
return await loop.run_in_executor( command=COMMAND_COMPONENT_ARM_DISARM,
executor, confirmation=confirmation,
lambda: self.arm_disarm( param1=1.0 if arm else 0.0,
target_sysid=target_sysid, param2=float(param2),
arm=arm, param3=0.0,
target_compid=target_compid, param4=0.0,
confirmation=confirmation, param5=0.0,
param2=param2, param6=0.0,
timeout_sec=timeout_sec, param7=0.0,
) timeout_sec=timeout_sec,
) )
async def takeoff_async( async def takeoff_async(
@@ -256,22 +337,20 @@ class CommandLongClient(Node):
longitude: Optional[float] = None, longitude: Optional[float] = None,
timeout_sec: float = DEFAULT_TIMEOUT_SEC, timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult: ) -> CommandLongResult:
"""非阻塞 async 版本的 takeoff(在 ThreadPoolExecutor 中運行)""" """非阻塞 async 版本的 takeoff"""
from concurrent.futures import ThreadPoolExecutor return await self._send_command_long_async(
loop = asyncio.get_event_loop() target_sysid=target_sysid,
executor = ThreadPoolExecutor(max_workers=1) target_compid=target_compid,
return await loop.run_in_executor( command=COMMAND_NAV_TAKEOFF,
executor, confirmation=0,
lambda: self.takeoff( param1=float(min_pitch_deg),
target_sysid=target_sysid, param2=0.0,
altitude_m=altitude_m, param3=0.0,
target_compid=target_compid, param4=float(yaw_deg),
min_pitch_deg=min_pitch_deg, param5=float(latitude) if latitude is not None else 0.0,
yaw_deg=yaw_deg, param6=float(longitude) if longitude is not None else 0.0,
latitude=latitude, param7=float(altitude_m),
longitude=longitude, timeout_sec=timeout_sec,
timeout_sec=timeout_sec,
)
) )
async def land_async( async def land_async(
@@ -285,19 +364,18 @@ class CommandLongClient(Node):
altitude_m: float = 0.0, altitude_m: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC, timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult: ) -> CommandLongResult:
"""非阻塞 async 版本的 land(在 ThreadPoolExecutor 中運行)""" """非阻塞 async 版本的 land"""
from concurrent.futures import ThreadPoolExecutor return await self._send_command_long_async(
loop = asyncio.get_event_loop() target_sysid=target_sysid,
executor = ThreadPoolExecutor(max_workers=1) target_compid=target_compid,
return await loop.run_in_executor( command=COMMAND_NAV_LAND,
executor, confirmation=0,
lambda: self.land( param1=0.0,
target_sysid=target_sysid, param2=0.0,
target_compid=target_compid, param3=0.0,
yaw_deg=yaw_deg, param4=float(yaw_deg),
latitude=latitude, param5=float(latitude) if latitude is not None else 0.0,
longitude=longitude, param6=float(longitude) if longitude is not None else 0.0,
altitude_m=altitude_m, param7=float(altitude_m),
timeout_sec=timeout_sec, timeout_sec=timeout_sec,
)
) )
+158 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import math import math
from dataclasses import dataclass from dataclasses import dataclass
@@ -122,10 +123,13 @@ class PositionTargetGlobalIntClient(Node):
並等待 POSITION_TARGET_GLOBAL_INT (msg 87) 回應(由 server 端處理)。 並等待 POSITION_TARGET_GLOBAL_INT (msg 87) 回應(由 server 端處理)。
""" """
def __init__(self, service_name: str = DEFAULT_SERVICE_NAME) -> None: def __init__(self, service_name: str = DEFAULT_SERVICE_NAME, node_name: str = None) -> None:
if not rclpy.ok(): if not rclpy.ok():
rclpy.init(args=None) rclpy.init(args=None)
super().__init__("fc_position_target_global_int_client") if node_name is None:
import time
node_name = f"fc_position_target_global_int_client_{int(time.time() * 1000) % 100000}"
super().__init__(node_name)
self._client = self.create_client(MavPositionTargetGlobalInt, service_name) self._client = self.create_client(MavPositionTargetGlobalInt, service_name)
def wait_for_service(self, timeout_sec: float = 3.0) -> bool: def wait_for_service(self, timeout_sec: float = 3.0) -> bool:
@@ -260,3 +264,155 @@ class PositionTargetGlobalIntClient(Node):
yaw_rate=0, yaw_rate=0,
timeout_sec=timeout_sec, timeout_sec=timeout_sec,
) )
# ============================================================================
# 非阻塞 async 版本(asyncio polling,對齊 longCommand.py 的模板)
# 避免在 Qt 事件迴圈或主執行緒中呼叫 spin_until_future_complete
# ============================================================================
async def _send_position_target_global_int_async(
self,
*,
target_sysid: int,
target_compid: int,
time_boot_ms: int,
coordinate_frame: int,
type_mask: int,
lat_int: int,
lon_int: int,
alt: float,
vx: float,
vy: float,
vz: float,
afx: float,
afy: float,
afz: float,
yaw: float,
yaw_rate: float,
timeout_sec: float,
) -> PositionTargetGlobalIntResult:
"""非阻塞 async 版本 - 使用 asyncio polling 而非 spin"""
req = MavPositionTargetGlobalInt.Request()
req.target_sysid = target_sysid
req.target_compid = target_compid
req.time_boot_ms = time_boot_ms
req.coordinate_frame = coordinate_frame
req.type_mask = type_mask
req.lat_int = lat_int
req.lon_int = lon_int
req.alt = float(alt)
req.vx = float(vx)
req.vy = float(vy)
req.vz = float(vz)
req.afx = float(afx)
req.afy = float(afy)
req.afz = float(afz)
req.yaw = float(yaw)
req.yaw_rate = float(yaw_rate)
req.timeout_sec = float(timeout_sec)
future = self._client.call_async(req)
timeout = float(timeout_sec) + 1.0
elapsed = 0.0
poll_interval = 0.01 # 10ms - 與 GUI timer 頻率一致
while elapsed < timeout:
if future.done():
try:
resp = future.result()
if resp is None:
return PositionTargetGlobalIntResult(
success=False,
message="Service returned None.",
ack_result=-1,
)
if resp.ack_result != 0:
return PositionTargetGlobalIntResult(
success=False,
message=resp.message,
ack_result=resp.ack_result,
)
echo_ok, echo_detail = _echo_position_target_matches(
type_mask=type_mask,
lat_int=lat_int,
lon_int=lon_int,
alt=alt,
vx=vx,
vy=vy,
vz=vz,
afx=afx,
afy=afy,
afz=afz,
yaw=yaw,
yaw_rate=yaw_rate,
time_boot_ms=time_boot_ms,
resp=resp,
)
return PositionTargetGlobalIntResult(
success=echo_ok,
message=echo_detail,
ack_result=resp.ack_result,
r_time_boot_ms=int(resp.r_time_boot_ms),
r_type_mask=int(resp.r_type_mask),
r_lat_int=int(resp.r_lat_int),
r_lon_int=int(resp.r_lon_int),
r_alt=float(resp.r_alt),
r_vx=float(resp.r_vx),
r_vy=float(resp.r_vy),
r_vz=float(resp.r_vz),
r_afx=float(resp.r_afx),
r_afy=float(resp.r_afy),
r_afz=float(resp.r_afz),
r_yaw=float(resp.r_yaw),
r_yaw_rate=float(resp.r_yaw_rate),
)
except Exception as e:
return PositionTargetGlobalIntResult(
success=False,
message=f"Error getting result: {e}",
ack_result=-1,
)
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return PositionTargetGlobalIntResult(
success=False,
message=f"Service timeout after {timeout}s.",
ack_result=-1,
)
async def goto_position_only_async(
self,
*,
target_sysid: int,
target_compid: int = 0,
latitude_deg: float,
longitude_deg: float,
alt: float = 0.0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> PositionTargetGlobalIntResult:
"""非阻塞 async 版本的 goto_position_only"""
lat_int = int(round(latitude_deg * 1e7))
lon_int = int(round(longitude_deg * 1e7))
return await self._send_position_target_global_int_async(
target_sysid=target_sysid,
target_compid=target_compid,
time_boot_ms=0,
coordinate_frame=MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
type_mask=POSITION_ONLY,
lat_int=lat_int,
lon_int=lon_int,
alt=alt,
vx=0,
vy=0,
vz=0,
afx=0,
afy=0,
afz=0,
yaw=0,
yaw_rate=0,
timeout_sec=timeout_sec,
)
-45
View File
@@ -1,45 +0,0 @@
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
import time
# import mavros_msgs.srv
class TalkerNode(Node):
def __init__(self):
start_time = time.time()
super().__init__('talker_node')
end_time = time.time()
print(f"Node initialization took {end_time - start_time:.2f} seconds")
self.publisher_ = self.create_publisher(String, 'hahatest/_1', 10)
self.timer = self.create_timer(1.0, self.timer_callback) # 每秒執行一次
self.get_logger().info('TalkerNode has been started.')
def timer_callback(self):
msg = String()
msg.data = 'Hello, ROS 2!'
self.publisher_.publish(msg)
self.get_logger().info(f'Published: "{msg.data}"')
def main(args=None):
rclpy.init(args=args)
node = TalkerNode()
print("Before sleep")
time.sleep(5) # 等待 5 秒鐘
print("After sleep")
try:
start_time = time.time()
while time.time() - start_time < 10: # 持續 10 秒鐘
rclpy.spin_once(node)
time.sleep(1) # 每秒執行一次
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
+188
View File
@@ -0,0 +1,188 @@
import time
from pymavlink import mavutil
# # 強制 source_system=255
# master = mavutil.mavlink_connection('udpout:localhost:14561', source_system=255)
# print("Starting Force-Ping Test...")
# while True:
# # 1. 發送 Heartbeat (證明連線活著)
# master.mav.heartbeat_send(mavutil.mavlink.MAV_TYPE_GCS,
# mavutil.mavlink.MAV_AUTOPILOT_INVALID, 0, 0, 0)
# # 2. 強制發送 PING (target 1, 1)
# # 我們加一個計數器,方便在 tcpdump 裡辨識
# ping_seq = int(time.time()) % 100
# master.mav.ping_send(int(time.time() * 1e6), ping_seq, 1, 1)
# print(f"Sent Heartbeat and PING (seq {ping_seq})")
# # 3. 非阻塞讀取,避免卡死
# msg = master.recv_match(blocking=False)
# if msg:
# print(f"--> Received something: {msg.get_type()}")
# time.sleep(1) # 每秒噴一次
# -------------------------------------------------------------------
# # 改成 udpin,這會讓你的程式守在 14561 等模擬器送資料進來
# # 這樣你就能收到模擬器的 Heartbeat,建立連線後再發 PING
# master = mavutil.mavlink_connection('udpin:localhost:14561', source_system=255)
# print("Waiting for simulator heartbeat...")
# # 這一行很重要:先收到 Heartbeat,pymavlink 才會設定好 target 地址
# master.wait_heartbeat()
# print(f"Heartbeat from system {master.target_system} component {master.target_component}")
# while True:
# # 既然收到了,現在發 PING 就會順著原路回去
# master.mav.ping_send(int(time.time() * 1e6), 1, master.target_system, master.target_component)
# print("PING sent!")
# msg = master.recv_match(type='PING', blocking=True, timeout=2)
# if msg:
# print(f"Got PING response: {msg}")
# time.sleep(1)
# -------------------------------------------------------------------
# import time
# from pymavlink import mavutil
# # 核心修改:因為 MAVProxy 正在往 14561 噴資料,我們必須「接住」它
# # 使用 udpin 會讓你的程式在 14561 監聽
# master = mavutil.mavlink_connection('udpin:localhost:14561', source_system=254)
# print("正在等待 MAVProxy 轉發的 Heartbeat...")
# # 只要這行跑過,代表你跟 MAVProxy 握手成功了
# master.wait_heartbeat()
# print(f"成功連線到系統 {master.target_system}!")
# while True:
# # 1. 務必發送 Heartbeat,讓 MAVProxy 知道要把回應送回這個 Port
# master.mav.heartbeat_send(
# mavutil.mavlink.MAV_TYPE_GCS,
# mavutil.mavlink.MAV_AUTOPILOT_INVALID,
# 0, 0, 0
# )
# # 2. 修改 target_component 為 1 (飛控核心)
# # 注意:這裡刻意將 seq 設為動態,方便你在 watch PING 裡觀察
# p_seq = int(time.time()) % 255
# master.mav.ping_send(
# int(time.time() * 1e6),
# p_seq,
# 1,
# 1 # <--- 改成 1 試試看!
# )
# print(f"Sent PING seq {p_seq} to 1/1")
# # 3. 讀取所有訊息,看看有沒有 PING 回來
# # 有時候回應的 target_system 可能是 255 (你的 ID)
# msg = master.recv_match(type='PING', blocking=True, timeout=1.0)
# if msg:
# print(f"🎉 成功!收到回應: {msg}")
# time.sleep(1)
# -------------------------------------------------------------------
# import time
# from pymavlink import mavutil
# # 建議一樣用 udpin 監聽 MAVProxy 的輸出
# master = mavutil.mavlink_connection('udpin:localhost:14561', source_system=255)
# def get_time_ns():
# return int(time.time() * 1e9) # 轉為奈秒
# print("開始測試 TIMESYNC 響應...")
# while True:
# now_ns = get_time_ns()
# # 發送 TIMESYNC 請求
# # tc1=0 代表這是請求,ts1=當前時間
# master.mav.timesync_send(0, now_ns)
# # 等待回應
# msg = master.recv_match(type='TIMESYNC', blocking=True, timeout=1.0)
# if msg and msg.tc1 != 0:
# # 收到回應,msg.tc1 應該等於我們剛才送出的 now_ns
# rtt_ms = (get_time_ns() - msg.tc1) / 1e6
# print(f"🕒 TIMESYNC RTT: {rtt_ms:.3f} ms")
# break # 測試一次就好
# else:
# print("等待 TIMESYNC 回應中...")
# time.sleep(1)
# -------------------------------------------------------------------
import time
import statistics
from pymavlink import mavutil
# 連接到 MAVProxy 轉發的端口
master = mavutil.mavlink_connection('udpin:localhost:14561', source_system=255)
# 測試設定
TARGET_SYS = 2 # 你想測試的載具 ID
SAMPLES = 20 # 取 20 次樣本來算平均和抖動
rtt_history = []
print(f"開始測試對 System {TARGET_SYS} 的通道品質...")
def get_time_ns():
return int(time.time() * 1e9)
try:
while len(rtt_history) < SAMPLES:
ts1_send = get_time_ns()
# 發送 TIMESYNC (V1/V2 格式相容)
# 針對特定系統發送
master.mav.timesync_send(0, ts1_send)
# 等待回應 (過濾目標系統的訊息)
msg = master.recv_match(type='TIMESYNC', blocking=True, timeout=1.0)
if msg and msg.get_srcSystem() == TARGET_SYS and msg.tc1 != 0:
ts1_recv = get_time_ns()
rtt_ns = ts1_recv - msg.tc1
rtt_ms = rtt_ns / 1e6
rtt_history.append(rtt_ms)
print(f"Sample {len(rtt_history)}: RTT = {rtt_ms:.3f} ms")
else:
print("Request timeout or mismatched ID...")
time.sleep(0.1) # 快速採樣
# 4. 計算統計數據
avg_latency = statistics.mean(rtt_history)
std_dev = statistics.stdev(rtt_history) # 這就是 Jitter 的一種表現
max_rtt = max(rtt_history)
min_rtt = min(rtt_history)
print("\n--- 物理通道品質分析報告 ---")
print(f"目標載具 : System {TARGET_SYS}")
print(f"平均延遲 (Avg) : {avg_latency:.3f} ms")
print(f"最小延遲 (Min) : {min_rtt:.3f} ms")
print(f"最大延遲 (Max) : {max_rtt:.3f} ms")
print(f"抖動幅度 (Jitter): {std_dev:.3f} ms (標準差)")
if std_dev > 10:
print("警告:網路抖動過高,可能影響控制穩定性!")
else:
print("通道品質:良好")
except KeyboardInterrupt:
print("測試終止。")
@@ -0,0 +1,46 @@
from pymavlink.dialects.v20 import common as mavlink
class NullBuffer:
def write(self, data): pass
def seek(self, pos): pass
def tell(self): return 0
class CapturingBuffer:
def __init__(self):
self.last_data = b''
def write(self, data):
self.last_data = bytes(data)
def seek(self, pos): pass
def tell(self): return 0
# 1. 初始化(僅作為編碼器)
# file 參數可以是任何擁有 write() 方法的物件,這裡用 BytesIO 模擬
# 初始化方法一:使用 BytesIO
# import io
# out_buf = io.BytesIO()
# mav = mavlink.MAVLink(out_buf, srcSystem=1, srcComponent=191)
# 初始化方法二:使用自定義的 NullBuffer
mav = mavlink.MAVLink(NullBuffer(), srcSystem=1, srcComponent=191)
mav.seq = 254
# 2. 建立心跳包並取得二進制數據
# 連續產出「不同」的訊息物件
for i in range(3):
msg = mav.heartbeat_encode(mavlink.MAV_TYPE_GCS, 0, 0, 0, 0)
data = msg.pack(mav)
mav.seq = (mav.seq + 1) & 0xFF
# MAVLink 2 的序列號在第 3 個 Byte (Index 2)
print(f"第 {i} 次發送, Seq: {data[4]}, Raw: {data.hex()}")
print("分隔線")
buf = CapturingBuffer()
mav_buf2 = mavlink.MAVLink(buf, srcSystem=1, srcComponent=191)
for i in range(3):
mav_buf2.heartbeat_send(mavlink.MAV_TYPE_GCS, 0, 0, 0, 0)
data = buf.last_data
print(f"第 {i} 次發送, Seq: {data[4]}, Raw: {data.hex()}")
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
import asyncio
import serial_asyncio
SERIAL_PORT = '/dev/ttyUSB0' # 修改為你的 serial port
SERIAL_BAUDRATE = 115200 # 修改為你的 baudrate
UDP_REMOTE_IP = '192.168.1.100' # 修改為目標 IP
UDP_REMOTE_PORT = 5005 # 修改為目標 port
UDP_LOCAL_PORT = 5006 # 本地 UDP 監聽 port
class SerialToUDP(asyncio.Protocol):
def __init__(self, udp_transport):
self.udp_transport = udp_transport
def connection_made(self, transport):
self.transport = transport
def data_received(self, data):
# Serial 收到資料,轉發到 UDP
self.udp_transport.sendto(data, (UDP_REMOTE_IP, UDP_REMOTE_PORT))
def write_to_serial(self, data):
self.transport.write(data)
class UDPToSerial(asyncio.DatagramProtocol):
def __init__(self, serial_proto):
self.serial_proto = serial_proto
def datagram_received(self, data, addr):
# UDP 收到資料,轉發到 Serial
self.serial_proto.write_to_serial(data)
async def main():
loop = asyncio.get_running_loop()
# 定義協議工廠函數
def create_empty_protocol():
return asyncio.DatagramProtocol()
# 建立 UDP1 傳輸
udp_transport, _ = await loop.create_datagram_endpoint(
create_empty_protocol,
local_addr=('0.0.0.0', UDP_LOCAL_PORT)
)
# 建立 Serial 傳輸
serial_proto = SerialToUDP(udp_transport)
def get_serial_protocol():
return serial_proto
_, serial_transport = await serial_asyncio.create_serial_connection(
loop, get_serial_protocol, SERIAL_PORT, baudrate=SERIAL_BAUDRATE
)
# 建立 UDP2 監聽
udp_proto = UDPToSerial(serial_proto)
def get_udp_protocol():
return udp_proto
udp_listen_transport, _ = await loop.create_datagram_endpoint(
get_udp_protocol,
local_addr=('0.0.0.0', UDP_LOCAL_PORT)
)
# 保持運行
try:
await asyncio.Future()
except asyncio.CancelledError:
pass
if __name__ == '__main__':
asyncio.run(main())
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
# 網站清單
DOMAINS=("google.com" "smarter.nchu.edu.tw")
echo "網站 SSL 憑證剩餘天數:"
echo "---------------------------"
for domain in "${DOMAINS[@]}"; do
end_date=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null |
openssl x509 -noout -enddate | cut -d= -f2)
end_timestamp=$(date -d "$end_date" +%s)
now_timestamp=$(date +%s)
remaining_days=$(( (end_timestamp - now_timestamp) / 86400 ))
if [ $remaining_days -lt 0 ]; then
status="已過期 ❌"
elif [ $remaining_days -lt 15 ]; then
status="即將到期 ⚠️"
else
status="正常 ✅"
fi
printf "%-20s 到期日:%-25s 剩餘天數:%3d 天 %s\n" "$domain" "$end_date" "$remaining_days" "$status"
done
+40
View File
@@ -0,0 +1,40 @@
import socket
import sys
import select
# 設定來源 IP 與 Port
SRC_IP = '127.0.0.1' # 監聽所有介面
SRC_PORT = 16661 # 請自行修改
# 建立 UDP 監聽 socket
src_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
src_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
src_sock.bind((SRC_IP, SRC_PORT))
print(f"Listening for UDP on {SRC_IP}:{SRC_PORT}")
# 設定目標 Unix socket 路徑
UNIX_SOCKET_PATH = '/tmp/unix_socket_mavlink.sock' # 請自行修改
# 建立 Unix socket 連線
unix_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
unix_sock.connect(UNIX_SOCKET_PATH)
except Exception as e:
print(f"Failed to connect to unix socket: {e}")
sys.exit(1)
while True:
# 使用 select 監聽兩個 socket
readable, _, _ = select.select([src_sock, unix_sock], [], [])
for sock in readable:
if sock is src_sock:
data, addr = src_sock.recvfrom(4096)
if data:
unix_sock.sendall(data)
# print(f"Received UDP data from {addr}: {data}") # debug
# break # debug
elif sock is unix_sock:
data = unix_sock.recv(4096)
if data:
# 回送到最近收到資料的 UDP client
src_sock.sendto(data, addr)
Binary file not shown.