Compare commits

...

58 Commits

Author SHA1 Message Date
wenchun 7d0368e7ba chore: 移除 CLAUDE.md 不納入版控
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 month ago
wenchun c2196153ff feat: 多任務群組系統 — 四欄式面板、地圖框選分配、可調參數
- 新增 mission_group.py:MissionGroup 資料結構、DroneAssignDialog、GroupPanel
- GroupPanel 四欄式佈局:控制指令 / 任務規劃 / 任務參數 / 選取與分組
- 任務參數欄依任務類型動態切換(間距、高度、偏移量等),規劃時從 UI 讀值
- 框選/全選可直接分配無人機到群組,清除分組一鍵重置
- 地圖清理:移除右下角 overlay,功能整合至 GroupPanel
- 修復 JS clearSelectionMode 引用已移除元素導致框選/Grid Sweep 失效
- 每群組獨立 MissionExecutor,共用 MavlinkSender

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 month ago
wenchun 693a55c289 merge: 合併 master 最新內容,以 master 為基準解決衝突
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 month ago
wenchun 337ca8ce24 chore: 更新 .gitignore,移除不再使用的 test_mission.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 month ago
wenchun 837b06dab5 feat: 新增任務規劃系統 (M-Formation, Circle, Leader-Follower, Grid Sweep)
- mission_planner: 四種任務模式,Leader-Follower 支援 Bezier 曲線轉彎
- mission_executor: QTimer 驅動的多航點執行器
- command_sender: MAVLink setpoint 發送抽象層
- gui: 地圖任務模式選單 + 框選/路徑標記觸發
- map_layout: 任務模式下拉選單 + 確認路徑按鈕 + route bridge 信號
- verify_waypoints: 靜態航點圖 + 動畫模擬驗證 (Play/Pause/Reset)
2 months ago
wenchun 76b3b0f40c 合併 ken 分支,使用 ken 的版本 2 months ago
wenchun af2138fe64 更新 mission_planner 2 months ago
ken910606 b70b99d572 Release v1.0.0 2 months ago
ken910606 8cd2a33840 Update GUI components 2 months ago
ken910606 46a07efce0 Update GUI components 2 months ago
ken910606 b2c2407e21 Ignore logs 2 months ago
ken910606 c3c50d87f6 Update GUI 2 months ago
wenchun 28810e5113 Merge remote-tracking branch 'origin/ken' into wenchun 2 months ago
ken910606 4f49ff815d Upload files to 'src/GUI' 3 months ago
ken910606 f2074d4b2a Upload files to 'src/GUI' 3 months ago
ken910606 136d5968d7 Upload files to 'src/GUI' 3 months ago
ken910606 f7f90c3168 Upload files to 'src/GUI' 3 months ago
ken910606 7694f29d1a Upload files to 'src/GUI' 3 months ago
ken910606 36c881b229 Upload files to 'src/GUI' 3 months ago
ken910606 8fdbbbc5dc update gui 3 months ago
ken910606 44d63979b5 ROS2 sub 3 months ago
ken910606 e54e42aad2 Bring fc_network_adapter from master 3 months ago
ken910606 9f1235197a Add/Update GUI module from wenchun 3 months ago
wenchun 317483a5c0 新增 GUI 資料夾與相關功能 3 months ago
ken910606 e4b658d578 Delete 'src/unitdev01/unitdev01/xbee.py' 4 months ago
ken910606 00eb6b512d Upload files to 'src/unitdev01/unitdev01' 4 months ago
ken910606 14b70f6e2e Upload files to 'src/unitdev01/unitdev01' 4 months ago
ken910606 4388ebd9ed item51&54 連接多個port 9 months ago
ken910606 94cf156187 加入路徑軌跡紀錄 9 months ago
ken910606 592fb36967 Upload files to 'src/unitdev01/unitdev01' 9 months ago
ken910606 e41e284442 Upload files to 'src/unitdev01/unitdev01' 9 months ago
ken910606 29457a2c15 Upload files to 'src/fc_network_adapter/fc_network_adapter' 9 months ago
ken910606 69aae9cae2 Upload files to 'src/unitdev01/unitdev01' 9 months ago
ken910606 47a681e27d Upload files to 'src/unitdev01/unitdev01' 10 months ago
ken910606 2783e4ba93 Upload files to 'src/unitdev01/unitdev01' 10 months ago
ken910606 55b77f5920 Upload files to 'src/fc_network_adapter/fc_network_adapter' 10 months ago
ken910606 6bbf2c450f Upload files to 'src/fc_network_adapter/fc_network_adapter' 10 months ago
ken910606 a55b76534f Upload files to 'src/fc_network_adapter/fc_network_adapter' 10 months ago
ken910606 68af81ac68 Upload files to 'src/unitdev01/unitdev01' 11 months ago
wenchun 303dfaf04a 新增 devRun.py 測試檔案 11 months ago
wenchun cd3c0f5bee Merge branch 'chiyu' into wenchun 11 months ago
wenchun 456d12ce56 Merge branch 'chiyu' into wenchun 11 months ago
ken910606 dc6e150293 Upload files to 'src/unitdev01/unitdev01' 12 months ago
ken910606 d4fc76c98b Upload files to 'src/unitdev01/unitdev01' 12 months ago
ken910606 800617dd3c Upload files to 'src/unitdev01/unitdev01' 12 months ago
ken910606 d32610d700 Upload files to 'src/unitdev01/unitdev01' 12 months ago
ken910606 fa7689eaf5 Delete 'src/fc_network_adapter/fc_network_adapter/socketManager.py' 12 months ago
ken910606 4b8efcc5f9 Upload files to 'src/fc_network_adapter/fc_network_adapter' 12 months ago
ken910606 1ff5df87c1 Upload files to 'src/fc_network_adapter/fc_network_adapter' 12 months ago
ken910606 b49a8b0314 Upload files to 'src/fc_network_adapter/fc_network_adapter' 12 months ago
wenchun 92ae83177f Merge branch 'wenchun' of http://140.120.108.238:49308/chiyu1468/AirTrapMine into wenchun 12 months ago
wenchun e4f0b2dc93 Matlab與Gazebo的閉環模擬所需程式 12 months ago
wenchun ce06a4160b 更新「README.md」
Signed-off-by: wenchun <milktea910607@gmail.com>
1 year ago
wenchun a934ccfa3c 實現close_loop的部份,主要是負責接收matlab回傳的封包,並以mavlink指令的方式打進去Ardupilot裡面 1 year ago
wenchun 598e80d070 新增fdm_switch_example_one_with_remote_forwarding這個副函式,可以把資料打包轉接到其他ip 1 year ago
wenchun f73e067c0b 之前簡易模擬的小程式 1 year ago
wenchun 30f29f678b 測試接收端 1 year ago
wenchun 8bc7bfee86 修改資料夾位置 1 year ago

@ -54,6 +54,12 @@ colcon build --packages-select fc_interfaces # 自己定義的
=== ===
Package 簡述 Package 簡述
<<<<<<< HEAD
===
簡述0328
主要是透過test_transform這隻程式執行fdm_switch_example_one_with_remote_forwarding這個副函式透過UDP將封包轉接到Matlab並在Matlab進行演算法迭代後打包JSON檔並再次透過port口傳回封包傳回來的封包會在close_loop這隻程式被解析並提取其中有關velocity的資料儲存成變數以mavlink的方式打入Ardupilot以實現封閉迴路的構思。
=======
1. unitdev 為各自協作者做開發時的測試區 1. unitdev 為各自協作者做開發時的測試區
01 -> 晉凱(ken) 01 -> 晉凱(ken)
02 -> 其宇(chiyu) 02 -> 其宇(chiyu)
@ -84,3 +90,4 @@ python -m fc_network_adapter.tests.demo_integration
``` ```
>>>>>>> chiyu

Binary file not shown.

@ -1,10 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import rclpy import rclpy
from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
QWidget, QLabel, QSplitter, QScrollArea, QWidget, QLabel, QSplitter, QScrollArea,
QSizePolicy, QTabWidget, QTableWidget, QTableWidgetItem, QSizePolicy, QTabWidget, QTableWidget, QTableWidgetItem,
QHeaderView, QPushButton, QCheckBox, QLineEdit) QHeaderView, QPushButton, QCheckBox, QLineEdit,
QComboBox, QDialog)
from PyQt6.QtCore import Qt, QTimer from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QColor
import sys import sys
import asyncio import asyncio
import json import json
@ -19,11 +21,14 @@ from comm_panel import CommPanel
from overview_table import OverviewTable 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
from mission_executor import MissionExecutor from mission_executor import MissionExecutor, MissionState
from mission_group import (
MissionGroup, GroupPanel, DroneAssignDialog, GROUP_COLORS
)
# ================================================================================ # ================================================================================
class ControlStationUI(QMainWindow): class ControlStationUI(QMainWindow):
@ -94,28 +99,22 @@ class ControlStationUI(QMainWindow):
base_altitude=10.0, # 基準高度 10 公尺 base_altitude=10.0, # 基準高度 10 公尺
altitude_diff=2.0 # 高低差 2 公尺 altitude_diff=2.0 # 高低差 2 公尺
) )
self.planned_waypoints = None # 儲存規劃結果
# ================================================================================ # ================================================================================
# ================================================================================ # ================================================================================
# 當前任務模式 (由地圖右上角下拉選單控制) # 初始化指令發送器(所有群組共用)
# ================================================================================ # ================================================================================
self.current_mission_mode = 'M_FORMATION' self.command_sender = MavlinkSender("udpout:127.0.0.1:14550") # 驗證階段寫死
# ================================================================================ # ================================================================================
# ================================================================================ # ================================================================================
# 初始化指令發送器與任務執行器 # 多任務群組管理
# ================================================================================ # ================================================================================
self.command_sender = MavlinkSender("udpout:127.0.0.1:14550") # 驗證階段寫死 self.mission_groups = {} # group_id → MissionGroup
self.group_panels = {} # group_id → GroupPanel
self.mission_executor = MissionExecutor( self.active_group_id = None # 當前 active 的 group
sender=self.command_sender, self._group_counter = 0 # 用來產生 group_id
drone_gps=self.monitor.drone_gps, # 直接傳引用,即時讀取 self._pending_box_assign = None # 框選後直接分配到的 group_id
arrival_radius=2.0,
send_rate_hz=2.0
)
self.mission_executor.drone_waypoint_reached.connect(self.on_drone_waypoint_reached)
self.mission_executor.mission_completed.connect(self.on_mission_completed)
# ================================================================================ # ================================================================================
self.init_ui() self.init_ui()
@ -163,97 +162,51 @@ class ControlStationUI(QMainWindow):
right_layout.setContentsMargins(10, 10, 10, 10) right_layout.setContentsMargins(10, 10, 10, 10)
right_layout.setSpacing(10) right_layout.setSpacing(10)
# ========== 批次控制區域 ========== # ========== 任務群組 Tab ==========
batch_control_layout = QHBoxLayout() group_header = QHBoxLayout()
group_title = QLabel("任務群組")
batch_title = QLabel("批次操作") group_title.setStyleSheet(
batch_title.setStyleSheet(""" "color: #DDD; font-size: 14px; font-weight: bold; padding: 2px;")
color: #DDD; font-size: 16px; font-weight: bold; group_header.addWidget(group_title)
padding: 5px; background-color: #333; border-radius: 4px; group_header.addStretch()
""")
batch_control_layout.addWidget(batch_title) add_group_btn = QPushButton("+ 新增群組")
add_group_btn.setStyleSheet("""
first_row = QHBoxLayout() QPushButton { background-color: #4A9EFF; color: white; border: none;
select_all_btn = QPushButton("全選") padding: 5px 12px; border-radius: 4px; font-size: 12px; font-weight: bold; }
select_all_btn.clicked.connect(self.handle_select_all) QPushButton:hover { background-color: #3A8EEF; }
select_all_btn.setStyleSheet("""
QPushButton { background-color: #444; color: #DDD; border: none;
padding: 8px 12px; border-radius: 4px; min-width: 80px; }
QPushButton:hover { background-color: #555; }
""") """)
first_row.addWidget(select_all_btn) add_group_btn.clicked.connect(self._add_mission_group)
first_row.addStretch() group_header.addWidget(add_group_btn)
mode_layout = QHBoxLayout() clear_traj_btn = QPushButton("清除軌跡")
mode_label = QLabel("模式:") clear_traj_btn.setStyleSheet("""
mode_label.setStyleSheet("color: #DDD; min-width: 40px;") QPushButton { background-color: #EF5350; color: white; border: none;
padding: 5px 12px; border-radius: 4px; font-size: 12px; font-weight: bold; }
from PyQt6.QtWidgets import QComboBox QPushButton:hover { background-color: #E53935; }
self.mode_combo = QComboBox()
self.mode_combo.addItems([
"GUIDED", "AUTO", "LAND", "LOITER",
"STABILIZE", "ACRO", "ALT_HOLD", "RTL",
"CIRCLE", "DRIFT", "SPORT", "FLIP",
"AUTOTUNE", "POSHOLD", "BRAKE", "THROW",
"AVOID_ADSB", "GUIDED_NOGPS", "SMART_RTL",
"FLOWHOLD", "FOLLOW", "ZIGZAG", "SYSTEMID",
"AUTOROTATE", "AUTO_RTL"
])
self.mode_combo.setCurrentIndex(1)
self.mode_combo.setStyleSheet("""
QComboBox { background-color: #333; color: #DDD; border-radius: 3px; padding: 2px 10px;}
""")
batch_mode_btn = QPushButton("切換")
batch_mode_btn.clicked.connect(self.handle_batch_mode_change)
batch_mode_btn.setStyleSheet("""
QPushButton { background-color: #444; color: #DDD; border: none;
padding: 8px 12px; border-radius: 4px; min-width: 80px; }
QPushButton:hover { background-color: #555; }
""") """)
mode_layout.addWidget(mode_label) clear_traj_btn.clicked.connect(self.drone_map.clear_trajectories)
mode_layout.addWidget(self.mode_combo) group_header.addWidget(clear_traj_btn)
mode_layout.addWidget(batch_mode_btn)
mode_layout.addStretch() right_layout.addLayout(group_header)
third_row = QHBoxLayout() self.group_tab_widget = QTabWidget()
arm_all_btn = QPushButton("解鎖") self.group_tab_widget.setStyleSheet("""
arm_all_btn.clicked.connect(self.handle_arm_selected) QTabWidget::pane { border: 1px solid #444; background-color: #2B2B2B; }
arm_all_btn.setStyleSheet(""" QTabBar::tab {
QPushButton { background-color: #444; color: #DDD; border: none; background-color: #333; color: #AAA; border: 1px solid #444;
padding: 8px 12px; border-radius: 4px; min-width: 80px; } padding: 5px 12px; margin-right: 2px; border-top-left-radius: 4px;
QPushButton:hover { background-color: #555; } border-top-right-radius: 4px; font-size: 12px;
""") }
third_row.addWidget(arm_all_btn) QTabBar::tab:selected { background-color: #2B2B2B; color: #FFF; border-bottom-color: #2B2B2B; }
third_row.addStretch() QTabBar::tab:hover { background-color: #3A3A3A; }
fourth_row = QHBoxLayout()
self.z_input = QLineEdit()
self.z_input.setFixedWidth(60)
self.z_input.setStyleSheet("""
QLineEdit { background-color: #333; color: #DDD;
border: 1px solid #555; border-radius: 4px; padding: 3px; }
""")
takeoff_all_btn = QPushButton("起飛")
takeoff_all_btn.clicked.connect(self.handle_takeoff_selected)
takeoff_all_btn.setStyleSheet("""
QPushButton { background-color: #444; color: #DDD; border: none;
padding: 8px 12px; border-radius: 4px; min-width: 80px; }
QPushButton:hover { background-color: #555; }
""") """)
self.group_tab_widget.setFixedHeight(150)
fourth_row.addWidget(QLabel("高度:", styleSheet="color: #DDD;")) self.group_tab_widget.currentChanged.connect(self._on_group_tab_changed)
fourth_row.addWidget(self.z_input) right_layout.addWidget(self.group_tab_widget)
fourth_row.addWidget(takeoff_all_btn)
fourth_row.addStretch()
batch_control_layout.addLayout(first_row)
batch_control_layout.addLayout(mode_layout)
batch_control_layout.addLayout(third_row)
batch_control_layout.addLayout(fourth_row)
right_layout.addLayout(batch_control_layout) # 預設建立 Group A
self._add_mission_group()
# 添加地圖 # 添加地圖
right_layout.addWidget(self.drone_map.get_widget()) right_layout.addWidget(self.drone_map.get_widget())
@ -263,13 +216,11 @@ class ControlStationUI(QMainWindow):
self.drone_map.get_toggle_select_all_drones_signal().connect(self.handle_toggle_select_all_drones) self.drone_map.get_toggle_select_all_drones_signal().connect(self.handle_toggle_select_all_drones)
# ================================================================================ # ================================================================================
# 連接任務控制 + 矩形選取 + 任務模式切換 + 路徑確認信號 # 連接地圖信號(任務模式改由 Group Tab 控制,不再從地圖下拉選單)
# ================================================================================ # ================================================================================
self.drone_map.get_start_mission_signal().connect(self.handle_start_mission)
self.drone_map.get_pause_mission_signal().connect(self.handle_pause_mission)
self.drone_map.get_rectangle_selected_signal().connect(self.handle_rectangle_selected) self.drone_map.get_rectangle_selected_signal().connect(self.handle_rectangle_selected)
self.drone_map.get_mission_mode_changed_signal().connect(self.on_mission_mode_changed)
self.drone_map.get_route_confirmed_signal().connect(self.handle_route_confirmed) self.drone_map.get_route_confirmed_signal().connect(self.handle_route_confirmed)
self.drone_map.get_drone_box_selected_signal().connect(self._handle_drone_box_selected)
# ================================================================================ # ================================================================================
main_splitter.addWidget(self.left_tab) main_splitter.addWidget(self.left_tab)
@ -426,7 +377,13 @@ class ControlStationUI(QMainWindow):
# ================================================================================ # ================================================================================
def handle_mode_change(self, drone_id): def handle_mode_change(self, drone_id):
mode = self.mode_combo.currentText() # 從 active group 的 mode_combo 讀取模式
group = self._get_active_group()
if group:
panel = self.group_panels.get(group.group_id)
mode = panel.mode_combo.currentText() if panel else "GUIDED"
else:
mode = "GUIDED"
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
future = self.monitor.set_mode(drone_id, mode) future = self.monitor.set_mode(drone_id, mode)
loop.create_task(self.handle_service_response(future, f"切換模式 {mode} {drone_id}")) loop.create_task(self.handle_service_response(future, f"切換模式 {mode} {drone_id}"))
@ -488,13 +445,266 @@ class ControlStationUI(QMainWindow):
for drone_id in self.monitor.selected_drones: for drone_id in self.monitor.selected_drones:
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}"))
def handle_batch_mode_change(self): # ================================================================================
mode = self.mode_combo.currentText() # 任務群組管理
# ================================================================================
def _next_group_id(self):
"""產生下一個群組 ID (A, B, C, ...)"""
gid = chr(ord('A') + self._group_counter)
self._group_counter += 1
return gid
def _add_mission_group(self):
"""新增一個任務群組"""
gid = self._next_group_id()
color = GROUP_COLORS[(self._group_counter - 1) % len(GROUP_COLORS)]
group = MissionGroup(gid, color)
self.mission_groups[gid] = group
panel = GroupPanel(group)
panel.assign_drones_requested.connect(self._handle_assign_drones)
panel.mission_type_changed.connect(self._handle_mission_type_changed)
panel.start_requested.connect(self._handle_group_start)
panel.pause_requested.connect(self._handle_group_pause)
panel.stop_requested.connect(self._handle_group_stop)
panel.mode_change_requested.connect(self._handle_group_mode_change)
panel.arm_requested.connect(self._handle_group_arm)
panel.takeoff_requested.connect(self._handle_group_takeoff)
panel.box_select_requested.connect(self._handle_box_select)
panel.select_all_requested.connect(self._handle_select_all_for_group)
panel.clear_group_requested.connect(self._handle_clear_group)
self.group_panels[gid] = panel
# 用帶顏色的 tab 標題
scroll = QScrollArea()
scroll.setWidget(panel)
scroll.setWidgetResizable(True)
idx = self.group_tab_widget.addTab(scroll, f"Group {gid}")
self.group_tab_widget.tabBar().setTabTextColor(idx, QColor(color))
self.group_tab_widget.setCurrentIndex(idx)
self.active_group_id = gid
self.statusBar().showMessage(f"已新增 Group {gid}", 2000)
def _on_group_tab_changed(self, index):
"""Tab 切換時更新 active group 並同步地圖模式"""
if index < 0:
self.active_group_id = None
return
# tab 標題是 "Group X"
tab_text = self.group_tab_widget.tabText(index)
gid = tab_text.replace("Group ", "")
if gid in self.mission_groups:
self.active_group_id = gid
group = self.mission_groups[gid]
self.drone_map.set_mission_mode(group.mission_type)
def _get_active_group(self):
"""取得當前 active 的 MissionGroup"""
if self.active_group_id and self.active_group_id in self.mission_groups:
return self.mission_groups[self.active_group_id]
return None
def _get_other_assigned(self, exclude_gid):
"""取得其他群組已佔用的無人機 {drone_id: group_id}"""
assigned = {}
for gid, group in self.mission_groups.items():
if gid == exclude_gid:
continue
for did in group.drone_ids:
assigned[did] = gid
return assigned
def _handle_assign_drones(self, group_id):
"""開啟無人機分配對話框(已勾選的 checkbox 會預先帶入)"""
group = self.mission_groups.get(group_id)
if not group:
return
all_ids = list(self.drones.keys())
other_assigned = self._get_other_assigned(group_id)
# 將目前 checkbox 已勾選的無人機(且未被其他群組佔用)合併進 pre-selected
currently_checked = self.get_selected_drones()
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)
if dialog.exec() == QDialog.DialogCode.Accepted:
group.drone_ids = dialog.get_selected()
panel = self.group_panels.get(group_id)
if panel:
panel.update_drone_list()
panel.update_status()
self.statusBar().showMessage(
f"Group {group_id}: 已分配 {len(group.drone_ids)} 台無人機", 3000)
def _handle_mission_type_changed(self, group_id, mission_type):
"""群組任務類型切換"""
group = self.mission_groups.get(group_id)
if group:
group.mission_type = mission_type
# 如果是 active group同步更新地圖的選擇模式
if group_id == self.active_group_id:
self.drone_map.set_mission_mode(mission_type)
def _create_executor_for_group(self, group):
"""為群組建立 MissionExecutor"""
executor = MissionExecutor(
sender=self.command_sender,
drone_gps=self.monitor.drone_gps,
arrival_radius=2.0,
send_rate_hz=2.0
)
executor.drone_waypoint_reached.connect(self.on_drone_waypoint_reached)
executor.mission_completed.connect(
lambda gid=group.group_id: self._on_group_mission_completed(gid))
group.executor = executor
def _handle_group_start(self, group_id):
"""啟動群組任務"""
group = self.mission_groups.get(group_id)
if not group:
return
if group.planned_waypoints is None:
self.statusBar().showMessage(f"⚠ Group {group_id}: 請先規劃任務", 3000)
return
if group.executor is None:
self._create_executor_for_group(group)
group.executor.start(group.planned_waypoints)
panel = self.group_panels.get(group_id)
if panel:
panel.update_status()
self.statusBar().showMessage(f"🚀 Group {group_id}: 任務已啟動", 3000)
def _handle_group_pause(self, group_id):
"""暫停/恢復群組任務"""
group = self.mission_groups.get(group_id)
if not group or not group.executor:
return
if group.executor.state == MissionState.RUNNING:
group.executor.pause()
self.statusBar().showMessage(f"⏸ Group {group_id}: 任務已暫停", 3000)
elif group.executor.state == MissionState.PAUSED:
group.executor.resume()
self.statusBar().showMessage(f"▶ Group {group_id}: 任務已恢復", 3000)
panel = self.group_panels.get(group_id)
if panel:
panel.update_status()
def _handle_group_stop(self, group_id):
"""停止群組任務"""
group = self.mission_groups.get(group_id)
if not group:
return
if group.executor:
group.executor.stop()
group.planned_waypoints = None
self.drone_map.clear_mission_plan_for_group(group_id)
panel = self.group_panels.get(group_id)
if panel:
panel.update_status()
panel.clear_mission_info()
self.statusBar().showMessage(f"■ Group {group_id}: 任務已停止", 3000)
def _handle_group_mode_change(self, group_id, mode):
"""切換群組內所有無人機的飛行模式"""
group = self.mission_groups.get(group_id)
if not group:
return
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
for drone_id in self.monitor.selected_drones: for drone_id in group.drone_ids:
future = self.monitor.set_mode(drone_id, mode) future = self.monitor.set_mode(drone_id, mode)
loop.create_task(self.handle_service_response(future, f"{drone_id} 切換模式 {mode}")) loop.create_task(self.handle_service_response(future, f"{drone_id} 切換 {mode}"))
def _handle_group_arm(self, group_id):
"""解鎖群組內所有無人機"""
group = self.mission_groups.get(group_id)
if not group:
return
loop = asyncio.get_event_loop()
for drone_id in group.drone_ids:
future = self.monitor.arm_drone(drone_id, True)
loop.create_task(self.handle_service_response(future, f"解鎖 {drone_id}"))
def _handle_group_takeoff(self, group_id, altitude):
"""群組內所有無人機起飛"""
group = self.mission_groups.get(group_id)
if not group:
return
loop = asyncio.get_event_loop()
for drone_id in group.drone_ids:
future = self.monitor.takeoff_drone(drone_id, altitude)
loop.create_task(self.handle_service_response(future, f"起飛 {drone_id} ({altitude}m)"))
def _handle_box_select(self, group_id):
"""觸發地圖框選 → 框選完成後直接分配到該群組"""
self._pending_box_assign = group_id
self.drone_map.toggle_drone_box_select()
self.statusBar().showMessage(
f"請在地圖上框選要分配到 Group {group_id} 的無人機", 5000)
def _handle_drone_box_selected(self, drone_ids_json):
"""地圖框選完成 — 直接分配到指定群組"""
group_id = self._pending_box_assign
self._pending_box_assign = None
if not group_id:
return
group = self.mission_groups.get(group_id)
if not group:
return
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}
group.drone_ids = valid
panel = self.group_panels.get(group_id)
if panel:
panel.update_drone_list()
panel.update_status()
self.statusBar().showMessage(
f"Group {group_id}: 框選分配 {len(valid)} 台無人機", 3000)
def _handle_select_all_for_group(self, group_id):
"""全選所有可用無人機,直接分配到該群組"""
group = self.mission_groups.get(group_id)
if not group:
return
other = self._get_other_assigned(group_id)
available = {did for did in self.drones.keys() if did not in other}
group.drone_ids = available
panel = self.group_panels.get(group_id)
if panel:
panel.update_drone_list()
panel.update_status()
self.statusBar().showMessage(
f"Group {group_id}: 全選分配 {len(available)} 台無人機", 3000)
def _handle_clear_group(self, group_id):
"""清除群組的無人機分配"""
group = self.mission_groups.get(group_id)
if not group:
return
group.drone_ids = set()
group.planned_waypoints = None
if group.executor:
group.executor.stop()
self.drone_map.clear_mission_plan_for_group(group_id)
panel = self.group_panels.get(group_id)
if panel:
panel.update_drone_list()
panel.update_status()
panel.clear_mission_info()
self.statusBar().showMessage(
f"Group {group_id}: 已清除分組", 3000)
def _on_group_mission_completed(self, group_id):
"""群組任務完成回呼"""
panel = self.group_panels.get(group_id)
if panel:
panel.update_status()
self.statusBar().showMessage(f"✅ Group {group_id}: 所有無人機已完成任務", 5000)
# ================================================================================ # ================================================================================
# UI 更新 # UI 更新
@ -610,73 +820,76 @@ class ControlStationUI(QMainWindow):
self.update_group_checkbox_state(socket_id) self.update_group_checkbox_state(socket_id)
# ================================================================================ # ================================================================================
# 任務模式切換 # 任務規劃 — 點擊地圖(路由到 active group
# ================================================================================ # ================================================================================
def on_mission_mode_changed(self, mode): def _get_group_drones(self, group):
self.current_mission_mode = mode """取得群組的無人機 ID 列表(排序後)"""
mode_names = { return sorted(group.drone_ids, key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
'M_FORMATION': '列隊飛行',
'CIRCLE_FORMATION': '環狀包圍',
'LEADER_FOLLOWER': '跟隨模式',
'GRID_SWEEP': '柵狀偵查'
}
display_name = mode_names.get(mode, mode)
self.statusBar().showMessage(f"🔄 任務模式: {display_name}", 3000)
print(f"任務模式切換: {mode}")
# ================================================================================
# 任務規劃 — 點擊地圖 (M-Formation / Circle)
# ================================================================================
def handle_map_click(self, lat, lon): def handle_map_click(self, lat, lon):
"""處理地圖點擊事件 — 根據選單模式規劃""" """處理地圖點擊事件 — 根據 active group 的任務類型規劃"""
print(f"地圖點擊位置: {lat:.6f}, {lon:.6f} (模式: {self.current_mission_mode})") group = self._get_active_group()
if not group:
# Grid Sweep 和 Leader-Follower 由各自的觸發方式處理,點擊地圖不處理 self.statusBar().showMessage("⚠ 請先建立任務群組", 3000)
return
mode_map = { mode_map = {
'M_FORMATION': MissionType.M_FORMATION, 'M_FORMATION': MissionType.M_FORMATION,
'CIRCLE_FORMATION': MissionType.CIRCLE_FORMATION, 'CIRCLE_FORMATION': MissionType.CIRCLE_FORMATION,
} }
mission_type = mode_map.get(self.current_mission_mode) mission_type = mode_map.get(group.mission_type)
if mission_type is None: if mission_type is None:
# Grid Sweep / Leader-Follower 模式下點擊地圖不處理 return # Grid Sweep / Leader-Follower 由各自的觸發方式處理
return
selected_drones = self._get_group_drones(group)
selected_drones = self.get_selected_drones()
if len(selected_drones) == 0: if len(selected_drones) == 0:
self.statusBar().showMessage("⚠ 請先選擇無人機(勾選 checkbox", 3000) self.statusBar().showMessage(f"⚠ Group {group.group_id}: 請先分配無人機", 3000)
return return
base_alt = 10.0 print(f"地圖點擊: {lat:.6f}, {lon:.6f} → Group {group.group_id} ({group.mission_type})")
panel = self.group_panels.get(group.group_id)
params = panel.get_mission_params() if panel else {}
base_alt = params.get('base_altitude', params.get('altitude', 10.0))
target_gps = (lat, lon, base_alt) target_gps = (lat, lon, base_alt)
self.statusBar().showMessage(f"⏳ 正在規劃 {self.current_mission_mode} ({len(selected_drones)} 台) ...", 2000) self.statusBar().showMessage(
f"⏳ Group {group.group_id}: 正在規劃 {group.mission_type} ({len(selected_drones)} 台) ...", 2000)
try: try:
drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt) drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt)
if drone_gps_positions is None: return if drone_gps_positions is None:
return
waypoints_per_drone, center_origin = self.mission_planner.plan_formation_mission( waypoints_per_drone, center_origin = self.mission_planner.plan_formation_mission(
drone_gps_positions, target_gps, mission_type drone_gps_positions, target_gps, mission_type, params=params
) )
self.planned_waypoints = {'drone_ids': selected_drones, 'waypoints': waypoints_per_drone} group.planned_waypoints = {'drone_ids': selected_drones, 'waypoints': waypoints_per_drone}
self.show_planned_waypoints() group.center_origin = center_origin
self.show_planned_waypoints(group)
center_lat, center_lon, _ = center_origin center_lat, center_lon, _ = center_origin
self.drone_map.draw_mission_plan(center_lat, center_lon, lat, lon) self.drone_map.draw_mission_plan_for_group(
group.group_id, group.color,
center_lat, center_lon, lat, lon
)
self._launch_verification( self._launch_verification(
self.current_mission_mode, drone_gps_positions, selected_drones, group.mission_type, drone_gps_positions, selected_drones,
waypoints_per_drone, center_origin, target_gps=target_gps waypoints_per_drone, center_origin, target_gps=target_gps
) )
panel = self.group_panels.get(group.group_id)
if panel:
panel.update_status()
panel.update_mission_info(center_lat, center_lon, lat, lon)
total_wps = sum(len(wps) for wps in waypoints_per_drone) total_wps = sum(len(wps) for wps in waypoints_per_drone)
self.statusBar().showMessage( self.statusBar().showMessage(
f"{self.current_mission_mode} 規劃完成!{len(selected_drones)} 台,共 {total_wps} 個航點", 5000 f"Group {group.group_id}: {group.mission_type} 規劃完成!{len(selected_drones)} 台,共 {total_wps} 個航點", 5000
) )
except Exception as e: except Exception as e:
self.statusBar().showMessage(f"規劃失敗: {str(e)}", 5000) self.statusBar().showMessage(f"Group {group.group_id}: 規劃失敗: {str(e)}", 5000)
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@ -685,66 +898,93 @@ class ControlStationUI(QMainWindow):
# ================================================================================ # ================================================================================
def handle_rectangle_selected(self, points_json): def handle_rectangle_selected(self, points_json):
print(f"矩形選取: {points_json}") group = self._get_active_group()
selected_drones = self.get_selected_drones() if not group:
self.statusBar().showMessage("⚠ 請先建立任務群組", 3000)
return
if group.mission_type != 'GRID_SWEEP':
return # 非 Grid Sweep 模式不處理矩形選取
selected_drones = self._get_group_drones(group)
if len(selected_drones) == 0: if len(selected_drones) == 0:
self.statusBar().showMessage("⚠ 請先選擇無人機再框選區域", 3000) self.statusBar().showMessage(f"⚠ Group {group.group_id}: 請先分配無人機", 3000)
return return
print(f"矩形選取 → Group {group.group_id}: {points_json}")
try: try:
rect_corners = [(p[0], p[1]) for p in json.loads(points_json)] rect_corners = [(p[0], p[1]) for p in json.loads(points_json)]
except (json.JSONDecodeError, IndexError): except (json.JSONDecodeError, IndexError):
self.statusBar().showMessage("❌ 矩形座標解析失敗", 3000) self.statusBar().showMessage("❌ 矩形座標解析失敗", 3000)
return return
base_alt = 10.0 panel = self.group_panels.get(group.group_id)
self.statusBar().showMessage(f"⏳ 正在規劃 Grid Sweep ({len(selected_drones)} 台) ...", 2000) params = panel.get_mission_params() if panel else {}
base_alt = params.get('altitude', 10.0)
self.statusBar().showMessage(
f"⏳ Group {group.group_id}: 正在規劃 Grid Sweep ({len(selected_drones)} 台) ...", 2000)
try: try:
drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt) drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt)
if drone_gps_positions is None: return if drone_gps_positions is None:
return
target_lat = sum(c[0] for c in rect_corners) / 4 target_lat = sum(c[0] for c in rect_corners) / 4
target_lon = sum(c[1] for c in rect_corners) / 4 target_lon = sum(c[1] for c in rect_corners) / 4
target_gps = (target_lat, target_lon, base_alt) target_gps = (target_lat, target_lon, base_alt)
params['rect_corners'] = rect_corners
waypoints_per_drone, center_origin = self.mission_planner.plan_formation_mission( waypoints_per_drone, center_origin = self.mission_planner.plan_formation_mission(
drone_gps_positions, target_gps, MissionType.GRID_SWEEP, drone_gps_positions, target_gps, MissionType.GRID_SWEEP,
params={'rect_corners': rect_corners, 'line_spacing': 5.0, 'altitude': base_alt} params=params
) )
self.planned_waypoints = {'drone_ids': selected_drones, 'waypoints': waypoints_per_drone} group.planned_waypoints = {'drone_ids': selected_drones, 'waypoints': waypoints_per_drone}
self.show_planned_waypoints() group.center_origin = center_origin
self.show_planned_waypoints(group)
center_lat, center_lon, _ = center_origin center_lat, center_lon, _ = center_origin
self.drone_map.draw_mission_plan(center_lat, center_lon, target_lat, target_lon) self.drone_map.draw_mission_plan_for_group(
group.group_id, group.color,
center_lat, center_lon, target_lat, target_lon
)
self._launch_verification( self._launch_verification(
'grid_sweep', drone_gps_positions, selected_drones, 'grid_sweep', drone_gps_positions, selected_drones,
waypoints_per_drone, center_origin, rect_corners=rect_corners waypoints_per_drone, center_origin, rect_corners=rect_corners
) )
panel = self.group_panels.get(group.group_id)
if panel:
panel.update_status()
panel.update_mission_info(center_lat, center_lon, target_lat, target_lon)
total_wps = sum(len(wps) for wps in waypoints_per_drone) total_wps = sum(len(wps) for wps in waypoints_per_drone)
self.statusBar().showMessage( self.statusBar().showMessage(
f"✓ Grid Sweep 規劃完成!{len(selected_drones)} 台,共 {total_wps} 個航點", 5000 f"✓ Group {group.group_id}: Grid Sweep 規劃完成!{len(selected_drones)} 台,共 {total_wps} 個航點", 5000
) )
except Exception as e: except Exception as e:
self.statusBar().showMessage(f"❌ Grid Sweep 規劃失敗: {str(e)}", 5000) self.statusBar().showMessage(f"❌ Group {group.group_id}: Grid Sweep 規劃失敗: {str(e)}", 5000)
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# ================================================================================ # ================================================================================
# 任務規劃 — 路徑確認 (Leader-Follower 跟隨模式) # 任務規劃 — 路徑確認 (Leader-Follower)
# ================================================================================ # ================================================================================
def handle_route_confirmed(self, points_json): def handle_route_confirmed(self, points_json):
"""路徑確認 → Leader-Follower 任務規劃""" group = self._get_active_group()
print(f"路徑確認: {points_json}") if not group:
self.statusBar().showMessage("⚠ 請先建立任務群組", 3000)
selected_drones = self.get_selected_drones() return
if group.mission_type != 'LEADER_FOLLOWER':
return
selected_drones = self._get_group_drones(group)
if len(selected_drones) == 0: if len(selected_drones) == 0:
self.statusBar().showMessage("⚠ 請先選擇無人機再標記路徑", 3000) self.statusBar().showMessage(f"⚠ Group {group.group_id}: 請先分配無人機", 3000)
return return
print(f"路徑確認 → Group {group.group_id}: {points_json}")
try: try:
route_points = json.loads(points_json) # [[lat, lon], ...] route_points = json.loads(points_json)
route_waypoints = [(p[0], p[1]) for p in route_points] route_waypoints = [(p[0], p[1]) for p in route_points]
except (json.JSONDecodeError, IndexError): except (json.JSONDecodeError, IndexError):
self.statusBar().showMessage("❌ 路徑座標解析失敗", 3000) self.statusBar().showMessage("❌ 路徑座標解析失敗", 3000)
@ -754,81 +994,67 @@ class ControlStationUI(QMainWindow):
self.statusBar().showMessage("⚠ 至少需要 2 個路徑點", 3000) self.statusBar().showMessage("⚠ 至少需要 2 個路徑點", 3000)
return return
base_alt = 10.0 panel = self.group_panels.get(group.group_id)
self.statusBar().showMessage(f"⏳ 正在規劃跟隨模式 ({len(selected_drones)} 台, {len(route_waypoints)} 個路徑點) ...", 2000) params = panel.get_mission_params() if panel else {}
base_alt = params.get('altitude', 10.0)
self.statusBar().showMessage(
f"⏳ Group {group.group_id}: 正在規劃跟隨模式 ({len(selected_drones)} 台) ...", 2000)
try: try:
drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt) drone_gps_positions = self._collect_drone_gps(selected_drones, base_alt)
if drone_gps_positions is None: if drone_gps_positions is None:
return return
# 目標點 = 路徑中心(供 origin 計算)
target_lat = sum(p[0] for p in route_waypoints) / len(route_waypoints) target_lat = sum(p[0] for p in route_waypoints) / len(route_waypoints)
target_lon = sum(p[1] for p in route_waypoints) / len(route_waypoints) target_lon = sum(p[1] for p in route_waypoints) / len(route_waypoints)
target_gps = (target_lat, target_lon, base_alt) target_gps = (target_lat, target_lon, base_alt)
params['route_waypoints'] = route_waypoints
waypoints_per_drone, center_origin = self.mission_planner.plan_formation_mission( waypoints_per_drone, center_origin = self.mission_planner.plan_formation_mission(
drone_gps_positions, drone_gps_positions, target_gps, MissionType.LEADER_FOLLOWER,
target_gps, params=params
MissionType.LEADER_FOLLOWER,
params={
'route_waypoints': route_waypoints,
'lateral_offset': 3.0,
'longitudinal_spacing': 5.0,
'altitude': base_alt
}
) )
self.planned_waypoints = {'drone_ids': selected_drones, 'waypoints': waypoints_per_drone} group.planned_waypoints = {'drone_ids': selected_drones, 'waypoints': waypoints_per_drone}
self.show_planned_waypoints() group.center_origin = center_origin
self.show_planned_waypoints(group)
center_lat, center_lon, _ = center_origin center_lat, center_lon, _ = center_origin
self.drone_map.draw_mission_plan(center_lat, center_lon, target_lat, target_lon) self.drone_map.draw_mission_plan_for_group(
group.group_id, group.color,
# 啟動視覺化驗證 center_lat, center_lon, target_lat, target_lon
)
self._launch_verification( self._launch_verification(
'LEADER_FOLLOWER', drone_gps_positions, selected_drones, 'LEADER_FOLLOWER', drone_gps_positions, selected_drones,
waypoints_per_drone, center_origin, waypoints_per_drone, center_origin,
target_gps=target_gps, route_waypoints=route_waypoints target_gps=target_gps, route_waypoints=route_waypoints
) )
panel = self.group_panels.get(group.group_id)
if panel:
panel.update_status()
panel.update_mission_info(center_lat, center_lon, target_lat, target_lon)
total_wps = sum(len(wps) for wps in waypoints_per_drone) total_wps = sum(len(wps) for wps in waypoints_per_drone)
self.statusBar().showMessage( self.statusBar().showMessage(
f"✓ 跟隨模式規劃完成!{len(selected_drones)} 台,{len(route_waypoints)} 個路徑點,共 {total_wps} 個航點", 5000 f"Group {group.group_id}: 跟隨模式規劃完成!{len(selected_drones)},共 {total_wps} 個航點", 5000
) )
except Exception as e: except Exception as e:
self.statusBar().showMessage(f"跟隨模式規劃失敗: {str(e)}", 5000) self.statusBar().showMessage(f"Group {group.group_id}: 跟隨模式規劃失敗: {str(e)}", 5000)
import traceback import traceback
traceback.print_exc() traceback.print_exc()
# ================================================================================ # ================================================================================
# 任務執行控制 # 任務執行回呼
# ================================================================================ # ================================================================================
def handle_start_mission(self, center_lat, center_lon, target_lat, target_lon):
if self.planned_waypoints is None:
self.statusBar().showMessage("⚠ 請先規劃任務", 3000)
return
self.mission_executor.start(self.planned_waypoints)
self.statusBar().showMessage("🚀 任務已啟動", 3000)
def handle_pause_mission(self):
if self.mission_executor.state.value == "running":
self.mission_executor.pause()
self.statusBar().showMessage("⏸ 任務已暫停", 3000)
elif self.mission_executor.state.value == "paused":
self.mission_executor.resume()
self.statusBar().showMessage("▶ 任務已恢復", 3000)
def on_drone_waypoint_reached(self, drone_id, wp_index, total): def on_drone_waypoint_reached(self, drone_id, wp_index, total):
if wp_index >= total: if wp_index >= total:
self.statusBar().showMessage(f"📍 {drone_id} 完成所有航點", 3000) self.statusBar().showMessage(f"📍 {drone_id} 完成所有航點", 3000)
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_mission_completed(self):
self.statusBar().showMessage("✅ 所有無人機已完成任務", 5000)
# ================================================================================ # ================================================================================
# 輔助方法 # 輔助方法
# ================================================================================ # ================================================================================
@ -877,20 +1103,23 @@ class ControlStationUI(QMainWindow):
subprocess.Popen([sys.executable, script_path, '--file', json_path]) subprocess.Popen([sys.executable, script_path, '--file', json_path])
print(f"驗證視窗已啟動: {json_path}") print(f"驗證視窗已啟動: {json_path}")
def show_planned_waypoints(self): def show_planned_waypoints(self, group=None):
if not self.planned_waypoints: return pw = group.planned_waypoints if group else None
print("\n" + "=" * 60) if not pw:
print("任務規劃結果") return
print("=" * 60) gid = group.group_id if group else "?"
drone_ids = self.planned_waypoints['drone_ids'] print(f"\n{'=' * 60}")
waypoints = self.planned_waypoints['waypoints'] print(f"任務規劃結果 — Group {gid}")
print(f"{'=' * 60}")
drone_ids = pw['drone_ids']
waypoints = pw['waypoints']
print(f"\n{len(drone_ids)} 台無人機") print(f"\n{len(drone_ids)} 台無人機")
for i, drone_id in enumerate(drone_ids): for i, drone_id in enumerate(drone_ids):
wps = waypoints[i] wps = waypoints[i]
print(f"\n{drone_id}】({len(wps)} 個航點)") print(f"\n{drone_id}】({len(wps)} 個航點)")
for j, wp in enumerate(wps): for j, wp in enumerate(wps):
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("\n" + "=" * 60) print(f"\n{'=' * 60}")
def get_selected_drones(self): def get_selected_drones(self):
return [did for did, panel in self.drones.items() if hasattr(panel, 'checkbox') and panel.checkbox.isChecked()] return [did for did, panel in self.drones.items() if hasattr(panel, 'checkbox') and panel.checkbox.isChecked()]
@ -1095,7 +1324,9 @@ class ControlStationUI(QMainWindow):
print(f"ROS spin error: {e}") print(f"ROS spin error: {e}")
def closeEvent(self, event): def closeEvent(self, event):
self.mission_executor.stop() for group in self.mission_groups.values():
if group.executor:
group.executor.stop()
self.command_sender.close() self.command_sender.close()
for receiver in self.udp_receivers: for receiver in self.udp_receivers:
receiver.stop() receiver.stop()

@ -178,35 +178,8 @@ class DroneMap:
</head> </head>
<body> <body>
<div id="map"></div> <div id="map"></div>
<div class="map-controls"> <div class="map-controls" id="map-controls-panel" style="display: none;">
<button class="selection-button" onclick="clearAllTrajectories()">清除軌跡</button> <button class="confirm-route-button" id="confirm-route-btn" onclick="confirmRoute()">確認路徑</button>
<div style="border-top: 1px solid #ddd; padding-top: 5px; margin-top: 5px;">
<label style="font-size: 12px; color: #555; font-weight: bold;">任務模式</label>
<select id="mission-mode-select" onchange="onMissionModeChanged(this.value)" style="width: 100%; padding: 6px; border-radius: 4px; border: 1px solid #ccc; font-size: 12px; margin-top: 3px;">
<option value="M_FORMATION">列隊飛行</option>
<option value="CIRCLE_FORMATION">環狀包圍</option>
<option value="LEADER_FOLLOWER">跟隨模式</option>
<option value="GRID_SWEEP">柵狀偵查</option>
</select>
</div>
<button class="confirm-route-button" id="confirm-route-btn" onclick="confirmRoute()" style="display: none;">確認路徑</button>
<button class="selection-button" id="select-polygon-btn" onclick="togglePolygonSelection()">多點選擇區域 (開發中)</button>
</div>
<div class="mission-info-panel">
<div class="selection-buttons">
<button class="selection-button-blue" onclick="toggleSelectAllDrones()">全選無人機</button>
<button class="selection-button-blue" id="select-drones-btn" onclick="toggleDroneSelection()">框選無人機</button>
</div>
<div class="mission-info-row">
<span class="mission-info-label">中心點: </span>
<span class="mission-info-value" id="center-position">未設定</span>
</div>
<div class="mission-info-row">
<span class="mission-info-label">目標點: </span>
<span class="mission-info-value" id="target-position">未設定</span>
</div>
<button class="mission-start-button" id="start-mission-btn" onclick="startMission()" disabled>開始任務</button>
<button class="mission-start-button" id="pause-mission-btn" onclick="pauseMission()">暫停任務</button>
</div> </div>
<script> <script>
@ -374,6 +347,9 @@ class DroneMap:
var missionLine = null; var missionLine = null;
var centerPosition = null; var centerPosition = null;
var targetPosition = null; var targetPosition = null;
// 多群組任務規劃 每個 group 各自的 layer group
var groupMissionLayers = {}; // group_id L.layerGroup
// 選擇工具變量 // 選擇工具變量
var selectionMode = null; // 'drones', 'rect', 'polygon', 'route', null var selectionMode = null; // 'drones', 'rect', 'polygon', 'route', null
@ -393,30 +369,8 @@ class DroneMap:
var routeLayer = L.layerGroup().addTo(map); var routeLayer = L.layerGroup().addTo(map);
// ================================================================================ // ================================================================================
// 更新任務信息面板 // 任務信息已移至 GroupPanel 顯示
function updateMissionInfo() { function updateMissionInfo() {}
const centerElem = document.getElementById('center-position');
const targetElem = document.getElementById('target-position');
const startBtn = document.getElementById('start-mission-btn');
if (centerPosition) {
centerElem.textContent = `${centerPosition.lat.toFixed(6)}°, ${centerPosition.lng.toFixed(6)}°`;
} else {
centerElem.textContent = '未設定';
}
if (targetPosition) {
targetElem.textContent = `${targetPosition.lat.toFixed(6)}°, ${targetPosition.lng.toFixed(6)}°`;
} else {
targetElem.textContent = '未設定';
}
if (centerPosition && targetPosition) {
startBtn.disabled = false;
} else {
startBtn.disabled = true;
}
}
// ================================================================================ // ================================================================================
// 選擇工具函數 // 選擇工具函數
@ -429,14 +383,14 @@ class DroneMap:
} }
function toggleDroneSelection() { function toggleDroneSelection() {
clearSelectionMode();
if (selectionMode === 'drones') { if (selectionMode === 'drones') {
selectionMode = null; selectionMode = null;
document.getElementById('select-drones-btn').classList.remove('active'); selectionLayer.clearLayers();
tempRectangle = null;
map.dragging.enable(); map.dragging.enable();
} else { } else {
clearSelectionMode();
selectionMode = 'drones'; selectionMode = 'drones';
document.getElementById('select-drones-btn').classList.add('active');
map.dragging.disable(); map.dragging.disable();
} }
} }
@ -450,7 +404,7 @@ class DroneMap:
clearSelectionMode(); clearSelectionMode();
clearPolygonPoints(); clearPolygonPoints();
selectionMode = null; selectionMode = null;
document.getElementById('select-polygon-btn').classList.remove('active'); // select-polygon-btn removed from map overlay
} }
} else { } else {
clearSelectionMode(); clearSelectionMode();
@ -462,17 +416,12 @@ class DroneMap:
} }
function clearSelectionMode() { function clearSelectionMode() {
document.getElementById('select-drones-btn').classList.remove('active');
document.getElementById('select-polygon-btn').classList.remove('active');
selectionLayer.clearLayers(); selectionLayer.clearLayers();
tempRectangle = null; tempRectangle = null;
// 不清除 routeLayer clearRoutePoints 單獨管理 // 不清除 routeLayer clearRoutePoints 單獨管理
map.dragging.enable(); map.dragging.enable();
// 如果離開 route 模式重置 selectionMode
if (selectionMode !== 'route') { if (selectionMode !== 'route') {
selectionMode = null; selectionMode = null;
} }
@ -527,7 +476,7 @@ class DroneMap:
} }
selectionMode = null; selectionMode = null;
document.getElementById('select-polygon-btn').classList.remove('active'); // select-polygon-btn removed from map overlay
map.dragging.enable(); map.dragging.enable();
} }
@ -554,6 +503,10 @@ class DroneMap:
} }
}); });
console.log('框選無人機:', selectedDrones); console.log('框選無人機:', selectedDrones);
// 通知 Python 完整的框選結果
if (bridge && selectedDrones.length > 0) {
bridge.droneBoxSelected(JSON.stringify(selectedDrones));
}
} else if (selectionMode === 'rect') { } else if (selectionMode === 'rect') {
var rectPoints = [ var rectPoints = [
@ -588,18 +541,7 @@ class DroneMap:
// 重置狀態 // 重置狀態
selectionMode = null; selectionMode = null;
document.getElementById('select-drones-btn').classList.remove('active');
map.dragging.enable(); map.dragging.enable();
// 如果仍在 Grid Sweep 模式重新進入框選
var currentMode = document.getElementById('mission-mode-select').value;
if (currentMode === 'GRID_SWEEP') {
setTimeout(function() {
selectionMode = 'rect';
map.dragging.disable();
console.log('Grid Sweep: 重新進入框選模式');
}, 500);
}
} }
// ================================================================================ // ================================================================================
@ -708,19 +650,19 @@ class DroneMap:
selectionMode = null; selectionMode = null;
clearSelectionMode(); clearSelectionMode();
clearRoutePoints(); clearRoutePoints();
// 隱藏確認路徑按鈕 // 預設隱藏控制面板
document.getElementById('confirm-route-btn').style.display = 'none'; document.getElementById('map-controls-panel').style.display = 'none';
if (mode === 'GRID_SWEEP') { if (mode === 'GRID_SWEEP') {
// 自動進入框選模式 // 自動進入框選模式
selectionMode = 'rect'; selectionMode = 'rect';
map.dragging.disable(); map.dragging.disable();
console.log('Grid Sweep: 進入框選模式'); console.log('Grid Sweep: 進入框選模式');
} else if (mode === 'LEADER_FOLLOWER') { } else if (mode === 'LEADER_FOLLOWER') {
// 進入路徑標記模式 // 進入路徑標記模式 + 顯示確認路徑按鈕
selectionMode = 'route'; selectionMode = 'route';
document.getElementById('confirm-route-btn').style.display = 'block'; document.getElementById('map-controls-panel').style.display = 'flex';
console.log('跟隨模式: 進入路徑標記模式,點擊地圖添加路徑點'); console.log('跟隨模式: 進入路徑標記模式,點擊地圖添加路徑點');
} }
@ -839,76 +781,74 @@ class DroneMap:
// 任務規劃視覺化函式 // 任務規劃視覺化函式
// ================================================================================ // ================================================================================
function drawMissionPlan(centerLat, centerLon, targetLat, targetLon) { function drawMissionPlan(centerLat, centerLon, targetLat, targetLon) {
clearMissionPlan(); drawMissionPlanForGroup('_default', '#FF4444', centerLat, centerLon, targetLat, targetLon);
}
function drawMissionPlanForGroup(groupId, color, centerLat, centerLon, targetLat, targetLon) {
clearMissionPlanForGroup(groupId);
centerPosition = {lat: centerLat, lng: centerLon}; centerPosition = {lat: centerLat, lng: centerLon};
targetPosition = {lat: targetLat, lng: targetLon}; targetPosition = {lat: targetLat, lng: targetLon};
updateMissionInfo(); updateMissionInfo();
var layerGroup = L.layerGroup().addTo(map);
groupMissionLayers[groupId] = layerGroup;
var centerIcon = L.divIcon({ var centerIcon = L.divIcon({
className: 'mission-center', className: 'mission-center',
html: `<div style=" html: '<div style="' +
background-color: #FF4444; 'background-color: ' + color + ';' +
color: white; 'color: white;' +
width: 20px; 'width: 22px; height: 22px;' +
height: 20px; 'border-radius: 50%;' +
border-radius: 50%; 'display: flex; align-items: center; justify-content: center;' +
display: flex; 'font-weight: bold; font-size: 10px;' +
align-items: center; 'border: 2px solid white;' +
justify-content: center; 'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' +
font-weight: bold; '">' + groupId + '</div>',
font-size: 12px; iconSize: [22, 22],
border: 2px solid white; iconAnchor: [11, 11]
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
">C</div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
}); });
centerMarker = L.marker([centerLat, centerLon], { L.marker([centerLat, centerLon], {
icon: centerIcon, icon: centerIcon, zIndexOffset: 2000
zIndexOffset: 2000 }).addTo(layerGroup);
}).addTo(missionPlanGroup);
var targetIcon = L.divIcon({ var targetIcon = L.divIcon({
className: 'mission-target', className: 'mission-target',
html: `<div style=" html: '<div style="' +
background-color: #FFD700; 'background-color: #FFD700;' +
color: #FF4444; 'color: ' + color + ';' +
width: 20px; 'width: 22px; height: 22px;' +
height: 20px; 'border-radius: 50%;' +
border-radius: 50%; 'display: flex; align-items: center; justify-content: center;' +
display: flex; 'font-weight: bold; font-size: 14px;' +
align-items: center; 'border: 2px solid white;' +
justify-content: center; 'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' +
font-weight: bold; '">★</div>',
font-size: 14px; iconSize: [22, 22],
border: 2px solid white; iconAnchor: [11, 11]
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
">★</div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
}); });
targetMarker = L.marker([targetLat, targetLon], { L.marker([targetLat, targetLon], {
icon: targetIcon, icon: targetIcon, zIndexOffset: 2000
zIndexOffset: 2000 }).addTo(layerGroup);
}).addTo(missionPlanGroup);
console.log('Group ' + groupId + ' 任務規劃已繪製');
console.log('任務規劃已繪製: C(' + centerLat + ', ' + centerLon + ') -> T(' + targetLat + ', ' + targetLon + ')');
} }
function clearMissionPlan() { function clearMissionPlanForGroup(groupId) {
if (centerMarker) { if (groupMissionLayers[groupId]) {
missionPlanGroup.removeLayer(centerMarker); map.removeLayer(groupMissionLayers[groupId]);
centerMarker = null; delete groupMissionLayers[groupId];
} }
}
if (targetMarker) {
missionPlanGroup.removeLayer(targetMarker); function clearAllMissionPlans() {
targetMarker = null; for (var gid in groupMissionLayers) {
map.removeLayer(groupMissionLayers[gid]);
} }
groupMissionLayers = {};
centerPosition = null; centerPosition = null;
targetPosition = null; targetPosition = null;
updateMissionInfo(); updateMissionInfo();
@ -969,17 +909,45 @@ class DroneMap:
# 任務規劃視覺化方法 # 任務規劃視覺化方法
# ================================================================================ # ================================================================================
def draw_mission_plan(self, center_lat, center_lon, target_lat, target_lon): def draw_mission_plan(self, center_lat, center_lon, target_lat, target_lon):
"""在地圖上繪製任務規劃""" """在地圖上繪製任務規劃(舊介面,相容用)"""
self.draw_mission_plan_for_group('_default', '#FF4444',
center_lat, center_lon, target_lat, target_lon)
def draw_mission_plan_for_group(self, group_id, color,
center_lat, center_lon, target_lat, target_lon):
"""在地圖上繪製指定群組的任務規劃(帶顏色區分)"""
if self.map_loaded: if self.map_loaded:
js_code = f"drawMissionPlan({center_lat:.6f}, {center_lon:.6f}, {target_lat:.6f}, {target_lon:.6f});" js_code = (
f"drawMissionPlanForGroup("
f"'{group_id}', '{color}', "
f"{center_lat:.6f}, {center_lon:.6f}, "
f"{target_lat:.6f}, {target_lon:.6f});"
)
self.map_view.page().runJavaScript(js_code) self.map_view.page().runJavaScript(js_code)
print(f"📍 地圖已繪製任務規劃: C({center_lat:.6f}, {center_lon:.6f}) -> T({target_lat:.6f}, {target_lon:.6f})") print(f"📍 地圖已繪製 Group {group_id} 任務規劃: "
f"C({center_lat:.6f}, {center_lon:.6f}) -> T({target_lat:.6f}, {target_lon:.6f})")
def clear_mission_plan(self): def clear_mission_plan(self):
"""清除地圖上的任務規劃標記""" """清除地圖上所有任務規劃標記"""
if self.map_loaded:
self.map_view.page().runJavaScript("clearAllMissionPlans();")
print("🗑️ 地圖已清除所有任務規劃")
def clear_mission_plan_for_group(self, group_id):
"""清除指定群組的任務規劃標記"""
if self.map_loaded: if self.map_loaded:
self.map_view.page().runJavaScript("clearMissionPlan();") self.map_view.page().runJavaScript(f"clearMissionPlanForGroup('{group_id}');")
print("🗑️ 地圖已清除任務規劃") print(f"🗑️ 地圖已清除 Group {group_id} 任務規劃")
def set_mission_mode(self, mode):
"""從 Python 端切換地圖的任務模式(觸發框選/路徑標記等)"""
if self.map_loaded:
self.map_view.page().runJavaScript(f"onMissionModeChanged('{mode}');")
def toggle_drone_box_select(self):
"""切換地圖上的無人機框選模式"""
if self.map_loaded:
self.map_view.page().runJavaScript("toggleDroneSelection();")
# ================================================================================ # ================================================================================
def get_widget(self): def get_widget(self):
@ -1029,6 +997,10 @@ class DroneMap:
def get_route_confirmed_signal(self): def get_route_confirmed_signal(self):
"""獲取路徑確認信號""" """獲取路徑確認信號"""
return self.bridge.route_confirmed return self.bridge.route_confirmed
def get_drone_box_selected_signal(self):
"""獲取框選無人機完成信號"""
return self.bridge.drone_box_selected
class MapBridge(QObject): class MapBridge(QObject):
"""JavaScript 和 Python 之間的橋接類""" """JavaScript 和 Python 之間的橋接類"""
@ -1042,7 +1014,8 @@ class MapBridge(QObject):
polygon_selected = pyqtSignal(str) polygon_selected = pyqtSignal(str)
mission_mode_changed = pyqtSignal(str) mission_mode_changed = pyqtSignal(str)
route_confirmed = pyqtSignal(str) # 路徑確認 (JSON 字串) route_confirmed = pyqtSignal(str) # 路徑確認 (JSON 字串)
drone_box_selected = pyqtSignal(str) # 框選無人機完成 (JSON 字串)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -1102,4 +1075,10 @@ class MapBridge(QObject):
def routeConfirmed(self, points_json): def routeConfirmed(self, points_json):
"""供 JavaScript 調用的方法 - 路徑確認""" """供 JavaScript 調用的方法 - 路徑確認"""
self.route_confirmed.emit(points_json) self.route_confirmed.emit(points_json)
print(f"📍 路徑已確認: {points_json}") print(f"📍 路徑已確認: {points_json}")
@pyqtSlot(str)
def droneBoxSelected(self, drone_ids_json):
"""供 JavaScript 調用的方法 - 框選無人機完成"""
self.drone_box_selected.emit(drone_ids_json)
print(f"📦 框選無人機: {drone_ids_json}")

@ -0,0 +1,495 @@
#!/usr/bin/env python3
"""
任務群組模組
管理多任務群組的資料結構與無人機分配對話框
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QComboBox, QDialog, QCheckBox, QScrollArea, QFrame, QLineEdit
)
from PyQt6.QtCore import Qt, pyqtSignal
from mission_executor import MissionExecutor, MissionState
# 群組顏色(循環使用)
GROUP_COLORS = [
'#4A9EFF', # 藍
'#FF8C42', # 橘
'#56C87A', # 綠
'#E85D75', # 紅
'#B07CED', # 紫
'#F5C542', # 黃
'#42C9C9', # 青
'#FF6B9D', # 粉
]
class MissionGroup:
"""單一任務群組的資料"""
def __init__(self, group_id, color):
self.group_id = group_id # 'A', 'B', 'C', ...
self.color = color # 顏色 hex
self.drone_ids = set() # 已分配的無人機 ID
self.mission_type = 'M_FORMATION' # 預設任務類型
self.planned_waypoints = None # 規劃結果 dict
self.executor = None # MissionExecutor 實例(延遲建立)
self.center_origin = None # 規劃原點
@property
def state(self):
if self.executor is None:
return MissionState.IDLE
return self.executor.state
@property
def display_name(self):
return f"Group {self.group_id}"
class DroneAssignDialog(QDialog):
"""無人機分配對話框"""
def __init__(self, parent, all_drone_ids, current_assigned, other_assigned):
"""
Args:
parent: widget
all_drone_ids: 所有可用無人機 ID 列表
current_assigned: 當前群組已分配的無人機 set
other_assigned: 其他群組已佔用的無人機 dict {drone_id: group_id}
"""
super().__init__(parent)
self.setWindowTitle("分配無人機")
self.setMinimumWidth(280)
self.setStyleSheet("""
QDialog { background-color: #2B2B2B; }
QLabel { color: #DDD; }
QCheckBox { color: #DDD; spacing: 8px; padding: 4px; }
QCheckBox::indicator { width: 16px; height: 16px; }
QCheckBox:disabled { color: #666; }
""")
layout = QVBoxLayout(self)
title = QLabel("選擇要分配到此群組的無人機:")
title.setStyleSheet("font-size: 13px; font-weight: bold; padding-bottom: 6px;")
layout.addWidget(title)
# 滾動區域
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setMaximumHeight(300)
scroll_widget = QWidget()
scroll_layout = QVBoxLayout(scroll_widget)
scroll_layout.setSpacing(2)
self.checkboxes = {}
sorted_ids = sorted(all_drone_ids, key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
for drone_id in sorted_ids:
cb = QCheckBox(drone_id)
if drone_id in current_assigned:
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
scroll_layout.addWidget(cb)
scroll_layout.addStretch()
scroll.setWidget(scroll_widget)
layout.addWidget(scroll)
# 按鈕
btn_layout = QHBoxLayout()
ok_btn = QPushButton("確定")
ok_btn.setStyleSheet("""
QPushButton { background-color: #4A9EFF; color: white; border: none;
padding: 8px 20px; border-radius: 4px; font-weight: bold; }
QPushButton:hover { background-color: #3A8EEF; }
""")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("取消")
cancel_btn.setStyleSheet("""
QPushButton { background-color: #555; color: #DDD; border: none;
padding: 8px 20px; border-radius: 4px; }
QPushButton:hover { background-color: #666; }
""")
cancel_btn.clicked.connect(self.reject)
btn_layout.addStretch()
btn_layout.addWidget(cancel_btn)
btn_layout.addWidget(ok_btn)
layout.addLayout(btn_layout)
def get_selected(self):
"""回傳勾選的無人機 ID set"""
return {did for did, cb in self.checkboxes.items() if cb.isChecked() and cb.isEnabled()}
class GroupPanel(QWidget):
"""單一群組的 UI 面板(嵌入到 tab 中)— 三欄式佈局"""
# 信號
assign_drones_requested = pyqtSignal(str) # group_id
mission_type_changed = pyqtSignal(str, str) # group_id, mission_type
start_requested = pyqtSignal(str) # group_id
pause_requested = pyqtSignal(str) # group_id
stop_requested = pyqtSignal(str) # group_id
mode_change_requested = pyqtSignal(str, str) # group_id, mode
arm_requested = pyqtSignal(str) # group_id
takeoff_requested = pyqtSignal(str, float) # group_id, altitude
box_select_requested = pyqtSignal(str) # group_id — 框選直接分配
select_all_requested = pyqtSignal(str) # group_id — 全選直接分配
clear_group_requested = pyqtSignal(str) # group_id — 清除分組
BUTTON_STYLE = """
QPushButton {{ background-color: {bg}; color: {fg}; border: none;
padding: 5px 8px; border-radius: 4px; font-size: 11px; }}
QPushButton:hover {{ background-color: {hover}; }}
QPushButton:disabled {{ background-color: #444; color: #666; }}
"""
def __init__(self, group: MissionGroup, parent=None):
super().__init__(parent)
self.group = group
self._build_ui()
def _make_sep(self):
"""建立垂直分隔線"""
sep = QFrame()
sep.setFrameShape(QFrame.Shape.VLine)
sep.setStyleSheet("color: #444;")
return sep
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 4, 6, 4)
layout.setSpacing(0)
COMBO = ("QComboBox { background-color: #333; color: #DDD; "
"border-radius: 3px; padding: 2px 6px; font-size: 11px; }")
BTN = self.BUTTON_STYLE
LBL = "color: #AAA; font-size: 11px;"
TITLE = "color: #DDD; font-size: 11px; font-weight: bold; padding-bottom: 2px;"
# ── 三欄主佈局 ──
cols = QHBoxLayout()
cols.setSpacing(6)
# ============================
# 左欄:控制指令
# ============================
left = QVBoxLayout()
left.setSpacing(3)
left_title = QLabel("控制指令")
left_title.setStyleSheet(TITLE)
left.addWidget(left_title)
# 模式切換
mode_row = QHBoxLayout()
mode_row.setSpacing(3)
self.mode_combo = QComboBox()
self.mode_combo.addItems([
"GUIDED", "AUTO", "LAND", "LOITER",
"STABILIZE", "ALT_HOLD", "RTL", "POSHOLD", "SMART_RTL"
])
self.mode_combo.setStyleSheet(COMBO)
mode_btn = QPushButton("切換")
mode_btn.setStyleSheet(BTN.format(bg='#555', fg='#DDD', hover='#666'))
mode_btn.clicked.connect(
lambda: self.mode_change_requested.emit(
self.group.group_id, self.mode_combo.currentText()))
mode_row.addWidget(self.mode_combo, 1)
mode_row.addWidget(mode_btn)
left.addLayout(mode_row)
# 解鎖
arm_btn = QPushButton("解鎖")
arm_btn.setStyleSheet(BTN.format(bg='#555', fg='#DDD', hover='#666'))
arm_btn.clicked.connect(
lambda: self.arm_requested.emit(self.group.group_id))
left.addWidget(arm_btn)
# 起飛
takeoff_row = QHBoxLayout()
takeoff_row.setSpacing(3)
self.alt_input = QComboBox()
self.alt_input.setEditable(True)
self.alt_input.addItems(["5", "10", "15", "20"])
self.alt_input.setCurrentText("10")
self.alt_input.setStyleSheet(COMBO)
alt_lbl = QLabel("m")
alt_lbl.setStyleSheet(LBL)
takeoff_btn = QPushButton("起飛")
takeoff_btn.setStyleSheet(BTN.format(bg='#555', fg='#DDD', hover='#666'))
takeoff_btn.clicked.connect(self._on_takeoff)
takeoff_row.addWidget(self.alt_input, 1)
takeoff_row.addWidget(alt_lbl)
takeoff_row.addWidget(takeoff_btn)
left.addLayout(takeoff_row)
left.addStretch()
# ============================
# 中欄:任務規劃(左右分割)
# ============================
mid = QVBoxLayout()
mid.setSpacing(2)
mid_title = QLabel("任務規劃")
mid_title.setStyleSheet(TITLE)
mid.addWidget(mid_title)
mid_body = QHBoxLayout()
mid_body.setSpacing(4)
# 左側:類型 + 狀態 + 座標
mid_left = QVBoxLayout()
mid_left.setSpacing(2)
self.type_combo = QComboBox()
self.type_combo.addItems([
"M_FORMATION", "CIRCLE_FORMATION", "LEADER_FOLLOWER", "GRID_SWEEP"
])
self.type_combo.setStyleSheet(COMBO)
self.type_combo.currentTextChanged.connect(
lambda t: self.mission_type_changed.emit(self.group.group_id, t))
mid_left.addWidget(self.type_combo)
self.status_label = QLabel("○ 未規劃")
self.status_label.setStyleSheet("color: #888; font-size: 11px;")
mid_left.addWidget(self.status_label)
self.center_label = QLabel("中心: --")
self.center_label.setStyleSheet("color: #AAA; font-size: 11px;")
mid_left.addWidget(self.center_label)
self.target_label = QLabel("目標: --")
self.target_label.setStyleSheet("color: #AAA; font-size: 11px;")
mid_left.addWidget(self.target_label)
mid_left.addStretch()
# 右側:執行 / 暫停 / 停止(垂直排列)
mid_right = QVBoxLayout()
mid_right.setSpacing(3)
self.start_btn = QPushButton("▶ 執行")
self.start_btn.setStyleSheet(BTN.format(bg='#2E7D32', fg='white', hover='#388E3C'))
self.start_btn.clicked.connect(
lambda: self.start_requested.emit(self.group.group_id))
self.pause_btn = QPushButton("⏸ 暫停")
self.pause_btn.setStyleSheet(BTN.format(bg='#F57F17', fg='white', hover='#F9A825'))
self.pause_btn.clicked.connect(
lambda: self.pause_requested.emit(self.group.group_id))
self.stop_btn = QPushButton("■ 停止")
self.stop_btn.setStyleSheet(BTN.format(bg='#C62828', fg='white', hover='#D32F2F'))
self.stop_btn.clicked.connect(
lambda: self.stop_requested.emit(self.group.group_id))
mid_right.addWidget(self.start_btn)
mid_right.addWidget(self.pause_btn)
mid_right.addWidget(self.stop_btn)
mid_right.addStretch()
mid_body.addLayout(mid_left, 1)
mid_body.addLayout(mid_right)
mid.addLayout(mid_body)
# ============================
# 選取與分組2x2 按鈕)
# ============================
right = QVBoxLayout()
right.setSpacing(3)
right_title = QLabel("選取與分組")
right_title.setStyleSheet(TITLE)
right.addWidget(right_title)
self.drone_list_label = QLabel("尚未分配")
self.drone_list_label.setStyleSheet("color: #888; font-size: 11px;")
self.drone_list_label.setWordWrap(True)
right.addWidget(self.drone_list_label)
# 2x2 按鈕網格
grid_r1 = QHBoxLayout()
grid_r1.setSpacing(3)
assign_btn = QPushButton("編輯分配")
assign_btn.setStyleSheet(BTN.format(bg='#555', fg='#DDD', hover='#666'))
assign_btn.clicked.connect(
lambda: self.assign_drones_requested.emit(self.group.group_id))
clear_btn = QPushButton("清除分組")
clear_btn.setStyleSheet(BTN.format(bg='#777', fg='white', hover='#888'))
clear_btn.clicked.connect(
lambda: self.clear_group_requested.emit(self.group.group_id))
grid_r1.addWidget(assign_btn)
grid_r1.addWidget(clear_btn)
right.addLayout(grid_r1)
grid_r2 = QHBoxLayout()
grid_r2.setSpacing(3)
box_btn = QPushButton("框選")
box_btn.setStyleSheet(BTN.format(bg='#64B5F6', fg='white', hover='#42A5F5'))
box_btn.clicked.connect(
lambda: self.box_select_requested.emit(self.group.group_id))
all_btn = QPushButton("全選")
all_btn.setStyleSheet(BTN.format(bg='#64B5F6', fg='white', hover='#42A5F5'))
all_btn.clicked.connect(
lambda: self.select_all_requested.emit(self.group.group_id))
grid_r2.addWidget(box_btn)
grid_r2.addWidget(all_btn)
right.addLayout(grid_r2)
right.addStretch()
# ============================
# 第四欄:任務參數
# ============================
param_col = QVBoxLayout()
param_col.setSpacing(2)
param_title = QLabel("任務參數")
param_title.setStyleSheet(TITLE)
param_col.addWidget(param_title)
INPUT = ("QLineEdit { background-color: #333; color: #DDD; "
"border: 1px solid #555; border-radius: 3px; "
"padding: 1px 4px; font-size: 11px; }")
# 每種任務類型的參數定義: (key, label, default_value)
self._param_defs = {
'M_FORMATION': [
('spacing', '間距 (m)', '5.0'),
('base_altitude', '基準高度 (m)', '10.0'),
('altitude_diff', '高低差 (m)', '2.0'),
],
'CIRCLE_FORMATION': [
('radius', '半徑 (m)', '10.0'),
('altitude', '高度 (m)', '10.0'),
('start_angle', '起始角 (°)', '0'),
],
'LEADER_FOLLOWER': [
('lateral_offset', '橫向偏移 (m)', '3.0'),
('longitudinal_spacing', '縱向間距 (m)', '5.0'),
('altitude', '高度 (m)', '10.0'),
],
'GRID_SWEEP': [
('line_spacing', '掃描線距 (m)', '5.0'),
('altitude', '高度 (m)', '10.0'),
],
}
# 建立所有參數列的 widget先全部建好切換時顯示/隱藏)
self._param_widgets = {} # key → (label_widget, input_widget)
self._param_rows = [] # 所有 row layout 對應的 container widget
for mission_type, defs in self._param_defs.items():
for key, label_text, default in defs:
if key in self._param_widgets:
continue # 同名參數只建一次
row_w = QWidget()
row_l = QHBoxLayout(row_w)
row_l.setContentsMargins(0, 0, 0, 0)
row_l.setSpacing(3)
lbl = QLabel(label_text)
lbl.setStyleSheet(LBL)
inp = QLineEdit(default)
inp.setStyleSheet(INPUT)
inp.setFixedWidth(50)
row_l.addWidget(lbl, 1)
row_l.addWidget(inp)
param_col.addWidget(row_w)
self._param_widgets[key] = (row_w, inp)
self._param_rows.append(row_w)
param_col.addStretch()
# 初始顯示對應的參數
self._update_param_visibility()
# 當任務類型切換時更新參數顯示
self.type_combo.currentTextChanged.connect(self._update_param_visibility)
# ── 組裝四欄:控制指令 > 任務規劃 > 任務參數 > 選取與分組 ──
cols.addLayout(left, 1)
cols.addWidget(self._make_sep())
cols.addLayout(mid, 1)
cols.addWidget(self._make_sep())
cols.addLayout(param_col, 1)
cols.addWidget(self._make_sep())
cols.addLayout(right, 1)
layout.addLayout(cols)
def update_drone_list(self):
"""更新無人機列表顯示"""
if not self.group.drone_ids:
self.drone_list_label.setText("尚未分配")
self.drone_list_label.setStyleSheet("color: #888; font-size: 11px;")
else:
sorted_ids = sorted(self.group.drone_ids,
key=lambda x: (x.split('_')[0], int(x.split('_')[1])))
self.drone_list_label.setText("".join(sorted_ids))
self.drone_list_label.setStyleSheet(
f"color: {self.group.color}; font-size: 11px; font-weight: bold;")
def update_status(self):
"""更新任務狀態顯示"""
state = self.group.state
if self.group.planned_waypoints is None:
self.status_label.setText("○ 未規劃")
self.status_label.setStyleSheet("color: #888; font-size: 11px;")
elif state == MissionState.RUNNING:
self.status_label.setText("▶ 執行中")
self.status_label.setStyleSheet("color: #4CAF50; font-size: 11px; font-weight: bold;")
elif state == MissionState.PAUSED:
self.status_label.setText("⏸ 已暫停")
self.status_label.setStyleSheet("color: #FFA000; font-size: 11px; font-weight: bold;")
else:
n = len(self.group.drone_ids)
total_wps = sum(len(wps) for wps in self.group.planned_waypoints['waypoints'])
self.status_label.setText(f"● 已規劃 ({n}架/{total_wps}點)")
self.status_label.setStyleSheet(
f"color: {self.group.color}; font-size: 11px; font-weight: bold;")
def _update_param_visibility(self, _=None):
"""根據當前任務類型,顯示/隱藏對應的參數列"""
mission_type = self.type_combo.currentText()
visible_keys = {d[0] for d in self._param_defs.get(mission_type, [])}
for key, (row_w, _inp) in self._param_widgets.items():
row_w.setVisible(key in visible_keys)
def get_mission_params(self):
"""讀取當前顯示的參數值,回傳 dict"""
mission_type = self.type_combo.currentText()
params = {}
for key, _label, default in self._param_defs.get(mission_type, []):
if key in self._param_widgets:
_row_w, inp = self._param_widgets[key]
try:
params[key] = float(inp.text())
except ValueError:
params[key] = float(default)
return params
def update_mission_info(self, center_lat, center_lon, target_lat, target_lon):
"""更新中心點 / 目標點顯示"""
info_style = f"color: {self.group.color}; font-size: 11px; font-weight: bold;"
self.center_label.setText(f"中心: {center_lat:.6f}°, {center_lon:.6f}°")
self.center_label.setStyleSheet(info_style)
self.target_label.setText(f"目標: {target_lat:.6f}°, {target_lon:.6f}°")
self.target_label.setStyleSheet(info_style)
def clear_mission_info(self):
"""清除中心點 / 目標點顯示"""
self.center_label.setText("中心: --")
self.center_label.setStyleSheet("color: #AAA; font-size: 11px;")
self.target_label.setText("目標: --")
self.target_label.setStyleSheet("color: #AAA; font-size: 11px;")
def _on_takeoff(self):
try:
alt = float(self.alt_input.currentText())
except ValueError:
alt = 10.0
self.takeoff_requested.emit(self.group.group_id, alt)

@ -0,0 +1,398 @@
#!/usr/bin/env python3
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QLineEdit)
from PyQt6.QtCore import pyqtSignal
class CommPanel(QWidget):
"""通讯设置面板类"""
# 定义信号
udp_connection_added = pyqtSignal(str, int) # ip, port
udp_connection_toggled = pyqtSignal(dict, QPushButton, QLabel) # conn, btn, status_label
udp_connection_removed = pyqtSignal(dict, QWidget) # conn, panel
ws_connection_added = pyqtSignal(str) # url
ws_connection_toggled = pyqtSignal(dict, QPushButton, QLabel) # conn, btn, status_label
ws_connection_removed = pyqtSignal(dict, QWidget) # conn, panel
status_message = pyqtSignal(str, int) # message, timeout
def __init__(self, parent=None):
super().__init__(parent)
self.udp_connections = []
self.ws_connections = []
self._init_ui()
def _init_ui(self):
"""初始化UI"""
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# ========== UDP MAVLink 區域 ==========
udp_title = QLabel("UDP")
udp_title.setStyleSheet("""
color: #DDD;
font-size: 14px;
font-weight: bold;
padding: 5px;
background-color: #333;
border-radius: 4px;
""")
layout.addWidget(udp_title)
# UDP 連接列表容器
self.udp_list_widget = QWidget()
self.udp_list_layout = QVBoxLayout(self.udp_list_widget)
self.udp_list_layout.setContentsMargins(0, 0, 0, 0)
self.udp_list_layout.setSpacing(5)
layout.addWidget(self.udp_list_widget)
# UDP 添加新連接區域
add_udp_widget = QWidget()
add_udp_layout = QHBoxLayout(add_udp_widget)
add_udp_layout.setContentsMargins(0, 0, 0, 0)
self.udp_ip_input = QLineEdit()
self.udp_ip_input.setText("127.0.0.1")
self.udp_ip_input.setPlaceholderText("IP")
self.udp_ip_input.setStyleSheet("""
QLineEdit {
background-color: #333;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 5px;
}
""")
self.udp_port_input = QLineEdit()
self.udp_port_input.setText("14540")
self.udp_port_input.setPlaceholderText("Port")
self.udp_port_input.setFixedWidth(80)
self.udp_port_input.setStyleSheet("""
QLineEdit {
background-color: #333;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 5px;
}
""")
add_udp_btn = QPushButton("添加")
add_udp_btn.clicked.connect(self._handle_add_udp)
add_udp_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
min-width: 30px;
}
QPushButton:hover { background-color: #45a049; }
""")
add_udp_layout.addWidget(QLabel("IP:", styleSheet="color: #DDD;"))
add_udp_layout.addWidget(self.udp_ip_input)
add_udp_layout.addWidget(QLabel("Port:", styleSheet="color: #DDD;"))
add_udp_layout.addWidget(self.udp_port_input)
add_udp_layout.addWidget(add_udp_btn)
layout.addWidget(add_udp_widget)
# 分隔線
separator = QWidget()
separator.setFixedHeight(20)
layout.addWidget(separator)
# ========== WebSocket 區域 ==========
ws_title = QLabel("WebSocket")
ws_title.setStyleSheet("""
color: #DDD;
font-size: 14px;
font-weight: bold;
padding: 5px;
background-color: #333;
border-radius: 4px;
""")
layout.addWidget(ws_title)
# WebSocket 連接列表容器
self.ws_list_widget = QWidget()
self.ws_list_layout = QVBoxLayout(self.ws_list_widget)
self.ws_list_layout.setContentsMargins(0, 0, 0, 0)
self.ws_list_layout.setSpacing(5)
layout.addWidget(self.ws_list_widget)
# WebSocket 添加新連接區域
add_ws_widget = QWidget()
add_ws_layout = QHBoxLayout(add_ws_widget)
add_ws_layout.setContentsMargins(0, 0, 0, 0)
self.ws_url_input = QLineEdit()
self.ws_url_input.setPlaceholderText("host")
self.ws_url_input.setStyleSheet("""
QLineEdit {
background-color: #333;
color: #DDD;
border: 1px solid #555;
border-radius: 4px;
padding: 5px;
}
""")
add_ws_btn = QPushButton("添加")
add_ws_btn.clicked.connect(self._handle_add_ws)
add_ws_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
min-width: 30px;
}
QPushButton:hover { background-color: #45a049; }
""")
add_ws_layout.addWidget(QLabel("URL:", styleSheet="color: #DDD;"))
add_ws_layout.addWidget(self.ws_url_input)
add_ws_layout.addWidget(add_ws_btn)
layout.addWidget(add_ws_widget)
layout.addStretch()
def _handle_add_udp(self):
"""處理添加 UDP 連接"""
ip = self.udp_ip_input.text().strip()
port_text = self.udp_port_input.text().strip()
if not ip or not port_text:
self.status_message.emit("請輸入 IP 和 Port", 3000)
return
try:
port = int(port_text)
if port < 1 or port > 65535:
raise ValueError("Port 超出範圍")
except ValueError:
self.status_message.emit("Port 必須是 1-65535 的數字", 3000)
return
# 檢查是否已存在相同連接
for conn in self.udp_connections:
if conn['ip'] == ip and conn['port'] == port:
self.status_message.emit("連接已存在", 3000)
return
# 發送信號通知主窗口
self.udp_connection_added.emit(ip, port)
# 清空輸入框
self.udp_ip_input.clear()
self.udp_port_input.clear()
def _handle_add_ws(self):
"""處理添加 WebSocket 連接"""
input_url = self.ws_url_input.text().strip()
if not input_url:
self.status_message.emit("請輸入 WebSocket URL", 3000)
return
# 自動添加 ws:// 前綴
if not input_url.startswith('ws://') and not input_url.startswith('wss://'):
url = f'ws://{input_url}'
else:
url = input_url
# 基本 URL 格式驗證
try:
if '://' in url:
parts = url.split('://', 1)
if len(parts) == 2 and ':' not in parts[1]:
self.status_message.emit("URL 格式錯誤,需要包含端口號 (例如: 127.0.0.1:8756)", 3000)
return
except:
self.status_message.emit("URL 格式錯誤", 3000)
return
# 檢查是否已存在相同連接
for conn in self.ws_connections:
if conn['url'] == url:
self.status_message.emit("連接已存在", 3000)
return
# 發送信號通知主窗口
self.ws_connection_added.emit(url)
# 清空輸入框
self.ws_url_input.clear()
def create_udp_connection_panel(self, conn):
"""創建 UDP 連接面板"""
panel = QWidget()
panel.setStyleSheet("""
QWidget {
background-color: #2A2A2A;
border-radius: 6px;
padding: 8px;
border: 1px solid #444;
}
""")
layout = QHBoxLayout(panel)
layout.setContentsMargins(8, 8, 8, 8)
# 連接資訊
info_label = QLabel(f"{conn['name']} - {conn['ip']}:{conn['port']}")
info_label.setStyleSheet("color: #DDD; font-size: 12px;")
# 狀態指示器
status_label = QLabel("")
if conn.get('enabled', False):
status_label.setStyleSheet("color: #4CAF50; font-size: 16px;")
status_label.setToolTip("運行中")
else:
status_label.setStyleSheet("color: #888; font-size: 16px;")
status_label.setToolTip("已停止")
# 控制按鈕
toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動")
toggle_btn.setFixedWidth(60)
toggle_btn.clicked.connect(lambda: self.udp_connection_toggled.emit(conn, toggle_btn, status_label))
toggle_btn.setStyleSheet("""
QPushButton {
background-color: #444;
color: #DDD;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
}
QPushButton:hover { background-color: #555; }
""")
remove_btn = QPushButton("移除")
remove_btn.setFixedWidth(60)
remove_btn.clicked.connect(lambda: self.udp_connection_removed.emit(conn, panel))
remove_btn.setStyleSheet("""
QPushButton {
background-color: #d32f2f;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
}
QPushButton:hover { background-color: #b71c1c; }
""")
layout.addWidget(status_label)
layout.addWidget(info_label)
layout.addStretch()
layout.addWidget(toggle_btn)
layout.addWidget(remove_btn)
# 儲存引用
panel.connection = conn
panel.toggle_btn = toggle_btn
panel.status_label = status_label
return panel
def create_ws_connection_panel(self, conn):
"""創建 WebSocket 連接面板"""
panel = QWidget()
panel.setStyleSheet("""
QWidget {
background-color: #2A2A2A;
border-radius: 6px;
padding: 8px;
border: 1px solid #444;
}
""")
layout = QHBoxLayout(panel)
layout.setContentsMargins(8, 8, 8, 8)
# 連接資訊
info_label = QLabel(f"{conn['name']} - {conn['url']}")
info_label.setStyleSheet("color: #DDD; font-size: 12px;")
# 狀態指示器
status_label = QLabel("")
if conn.get('enabled', False):
status_label.setStyleSheet("color: #4CAF50; font-size: 16px;")
status_label.setToolTip("運行中")
else:
status_label.setStyleSheet("color: #888; font-size: 16px;")
status_label.setToolTip("已停止")
# 控制按鈕
toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動")
toggle_btn.setFixedWidth(60)
toggle_btn.clicked.connect(lambda: self.ws_connection_toggled.emit(conn, toggle_btn, status_label))
toggle_btn.setStyleSheet("""
QPushButton {
background-color: #444;
color: #DDD;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
}
QPushButton:hover { background-color: #555; }
""")
remove_btn = QPushButton("移除")
remove_btn.setFixedWidth(60)
remove_btn.clicked.connect(lambda: self.ws_connection_removed.emit(conn, panel))
remove_btn.setStyleSheet("""
QPushButton {
background-color: #d32f2f;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-size: 11px;
}
QPushButton:hover { background-color: #b71c1c; }
""")
layout.addWidget(status_label)
layout.addWidget(info_label)
layout.addStretch()
layout.addWidget(toggle_btn)
layout.addWidget(remove_btn)
# 儲存引用
panel.connection = conn
panel.toggle_btn = toggle_btn
panel.status_label = status_label
return panel
def add_udp_panel(self, conn):
"""添加 UDP 連接面板到列表"""
panel = self.create_udp_connection_panel(conn)
self.udp_list_layout.addWidget(panel)
self.udp_connections.append(conn)
return panel
def add_ws_panel(self, conn):
"""添加 WebSocket 連接面板到列表"""
panel = self.create_ws_connection_panel(conn)
self.ws_list_layout.addWidget(panel)
self.ws_connections.append(conn)
return panel
def remove_udp_connection_from_list(self, conn):
"""從列表中移除 UDP 連接"""
if conn in self.udp_connections:
self.udp_connections.remove(conn)
def remove_ws_connection_from_list(self, conn):
"""從列表中移除 WebSocket 連接"""
if conn in self.ws_connections:
self.ws_connections.remove(conn)

@ -0,0 +1,646 @@
from rclpy.node import Node
from PyQt6.QtCore import QObject, pyqtSignal
import math
import re
import threading
from threading import Lock
import asyncio
import websockets
import json
import socket
from pymavlink import mavutil
from geometry_msgs.msg import Point, Vector3
from sensor_msgs.msg import BatteryState, NavSatFix, Imu
from std_msgs.msg import Float64
from mavros_msgs.msg import State, VfrHud
from mavros_msgs.srv import CommandBool, CommandTOL
class DroneSignals(QObject):
update_signal = pyqtSignal(str, str, object) # (msg_type, drone_id, data)
class UDPMavlinkReceiver(threading.Thread):
"""UDP MAVLink 接收器"""
def __init__(self, ip, port, signals, connection_name):
super().__init__(daemon=True)
self.ip = ip
self.port = port
self.signals = signals
self.connection_name = connection_name
self.running = False
self.sock = None
def run(self):
"""執行 UDP 接收循環"""
self.running = True
try:
print(f"UDP MAVLink receiver started on {self.ip}:{self.port}")
# 創建 MAVLink 連接
mav = mavutil.mavlink_connection(f'udpin:{self.ip}:{self.port}')
while self.running:
try:
msg = mav.recv_match(blocking=True, timeout=1.0)
if msg is None:
continue
self.process_mavlink_message(msg)
except socket.timeout:
continue
except Exception as e:
print(f"Error receiving MAVLink message: {e}")
except Exception as e:
print(f"UDP receiver error: {e}")
finally:
if self.sock:
self.sock.close()
def process_mavlink_message(self, msg):
"""處理 MAVLink 訊息"""
try:
msg_type = msg.get_type()
system_id = msg.get_srcSystem()
drone_id = f"s8_{system_id}" # 使用 s8_ 前綴表示 UDP 來源
if msg_type == "HEARTBEAT":
mode = mavutil.mode_string_v10(msg)
armed = bool(msg.base_mode & 128)
self.signals.update_signal.emit('state', drone_id, {
'mode': mode,
'armed': armed
})
elif msg_type == "BATTERY_STATUS":
voltage = msg.voltages[0] / 1000
self.signals.update_signal.emit('battery', drone_id, {
'voltage': voltage
})
elif msg_type == "GLOBAL_POSITION_INT":
latitude = msg.lat / 1e7
longitude = msg.lon / 1e7
self.signals.update_signal.emit('gps', drone_id, {
'lat': latitude,
'lon': longitude
})
elif msg_type == "GPS_RAW_INT":
fix_type = msg.fix_type
elif msg_type == "LOCAL_POSITION_NED":
x = msg.y
y = msg.x
z = -msg.z
self.signals.update_signal.emit('local_pose', drone_id, {
'x': x,
'y': y,
'z': z
})
self.signals.update_signal.emit('altitude', drone_id, {
'altitude': z
})
self.signals.update_signal.emit('velocity', drone_id, {
'vx': msg.vx,
'vy': msg.vy,
'vz': msg.vz
})
elif msg_type == "ATTITUDE":
pitch = math.degrees(msg.pitch)
self.signals.update_signal.emit('attitude', drone_id, {
'pitch': pitch,
'roll': 0,
'yaw': 0,
'rates': (0, 0, 0)
})
elif msg_type == "VFR_HUD":
groundspeed = msg.groundspeed
heading = msg.heading
self.signals.update_signal.emit('hud', drone_id, {
'heading': heading,
'groundspeed': groundspeed
})
except Exception as e:
print(f"Error processing MAVLink message: {e}")
def stop(self):
"""停止接收器"""
self.running = False
class SerialMavlinkReceiver(threading.Thread):
"""串口 MAVLink 接收器"""
def __init__(self, port, baudrate, signals, connection_name):
super().__init__(daemon=True)
self.port = port
self.baudrate = baudrate
self.signals = signals
self.connection_name = connection_name
self.running = False
self.mav = None
def run(self):
"""執行串口接收循環"""
self.running = True
try:
print(f"Serial MAVLink receiver started on {self.port} at {self.baudrate} baud")
# 創建 MAVLink 串口連接
self.mav = mavutil.mavlink_connection(
self.port,
baud=self.baudrate,
source_system=255
)
print(f"Waiting for heartbeat from {self.port}...")
self.mav.wait_heartbeat()
print(f"Heartbeat received from system {self.mav.target_system}, component {self.mav.target_component}")
while self.running:
try:
msg = self.mav.recv_match(blocking=True, timeout=1.0)
if msg is None:
continue
self.process_mavlink_message(msg)
except Exception as e:
if self.running:
print(f"Error receiving MAVLink message from serial: {e}")
except Exception as e:
print(f"Serial receiver error: {e}")
finally:
if self.mav:
try:
self.mav.close()
except:
pass
def process_mavlink_message(self, msg):
"""處理 MAVLink 訊息"""
try:
msg_type = msg.get_type()
system_id = msg.get_srcSystem()
drone_id = f"s5_{system_id}" # 使用 serial_ 前綴表示串口來源
if msg_type == "HEARTBEAT":
mode = mavutil.mode_string_v10(msg)
armed = bool(msg.base_mode & 128)
self.signals.update_signal.emit('state', drone_id, {
'mode': mode,
'armed': armed
})
elif msg_type == "BATTERY_STATUS":
voltage = msg.voltages[0] / 1000
self.signals.update_signal.emit('battery', drone_id, {
'voltage': voltage
})
elif msg_type == "GLOBAL_POSITION_INT":
latitude = msg.lat / 1e7
longitude = msg.lon / 1e7
self.signals.update_signal.emit('gps', drone_id, {
'lat': latitude,
'lon': longitude
})
elif msg_type == "GPS_RAW_INT":
fix_type = msg.fix_type
elif msg_type == "LOCAL_POSITION_NED":
x = msg.y
y = msg.x
z = -msg.z
self.signals.update_signal.emit('local_pose', drone_id, {
'x': x,
'y': y,
'z': z
})
self.signals.update_signal.emit('altitude', drone_id, {
'altitude': z
})
self.signals.update_signal.emit('velocity', drone_id, {
'vx': msg.vx,
'vy': msg.vy,
'vz': msg.vz
})
elif msg_type == "ATTITUDE":
pitch = math.degrees(msg.pitch)
self.signals.update_signal.emit('attitude', drone_id, {
'pitch': pitch,
'roll': 0,
'yaw': 0,
'rates': (0, 0, 0)
})
elif msg_type == "VFR_HUD":
groundspeed = msg.groundspeed
heading = msg.heading
self.signals.update_signal.emit('hud', drone_id, {
'heading': heading,
'groundspeed': groundspeed
})
except Exception as e:
print(f"Error processing MAVLink message from serial: {e}")
def stop(self):
"""停止接收器"""
self.running = False
class WebSocketMavlinkReceiver(threading.Thread):
"""WebSocket MAVLink 接收器"""
def __init__(self, url, signals, connection_name):
super().__init__(daemon=True)
self.url = url
self.signals = signals
self.connection_name = connection_name
self.running = False
self.max_retries = 5
self.base_delay = 1.0
def run(self):
"""執行 WebSocket 接收循環"""
self.running = True
asyncio.set_event_loop(asyncio.new_event_loop())
asyncio.get_event_loop().run_until_complete(self.ws_client_loop())
async def ws_client_loop(self):
"""WebSocket 連接的主循環"""
retry_count = 0
print(f"Starting WebSocket client for {self.connection_name} at {self.url}")
while self.running and retry_count < self.max_retries:
try:
async with websockets.connect(self.url) as websocket:
print(f"WebSocket {self.connection_name} connected to {self.url}")
retry_count = 0 # 重置重試計數
async for message in websocket:
if not self.running:
break
try:
data = json.loads(message)
data['_connection_source'] = self.connection_name
self.process_websocket_message(data)
except json.JSONDecodeError as e:
print(f"WebSocket {self.connection_name} JSON decode error: {e}")
except Exception as e:
print(f"WebSocket {self.connection_name} message processing error: {e}")
except websockets.exceptions.ConnectionClosedError:
print(f"WebSocket {self.connection_name} connection closed")
if self.running:
retry_count += 1
if retry_count < self.max_retries:
delay = self.base_delay * (2 ** min(retry_count, 4))
print(f"Reconnecting in {delay}s...")
await asyncio.sleep(delay)
else:
break
except Exception as e:
retry_count += 1
if retry_count < self.max_retries and self.running:
delay = self.base_delay * (2 ** min(retry_count, 4))
print(f"WebSocket {self.connection_name} connection error: {e}, retrying in {delay}s (attempt {retry_count}/{self.max_retries})")
await asyncio.sleep(delay)
else:
break
print(f"WebSocket client {self.connection_name} stopped")
def process_websocket_message(self, data):
"""處理 WebSocket 訊息"""
try:
drone_id = data.get('system_id')
drone_id = f"s9_{drone_id}" if drone_id else None
if not drone_id:
return
# 模式
if 'mode' in data:
self.signals.update_signal.emit('state', drone_id, {
'mode': data['mode'],
})
# 電池
if 'battery' in data:
self.signals.update_signal.emit('battery', drone_id, {
'percentage': data['battery']
})
# 位置
if 'position' in data:
pos = data['position']
self.signals.update_signal.emit('gps', drone_id, {
'lat': pos.get('lat', 0),
'lon': pos.get('lon', 0)
})
# Local position - 設定 x, y 為 0.0
self.signals.update_signal.emit('local_pose', drone_id, {
'x': 0.0,
'y': 0.0,
'z': 0.0
})
# Altitude - 設定為 0.0
self.signals.update_signal.emit('altitude', drone_id, {
'altitude': 0.0
})
# 航向
if 'heading' in data:
self.signals.update_signal.emit('hud', drone_id, {
'heading': data['heading'],
'groundspeed': 0.0
})
except Exception as e:
print(f"WebSocket message processing error: {e}")
def stop(self):
"""停止接收器"""
self.running = False
class DroneMonitor(Node):
def __init__(self):
super().__init__('drone_monitor')
self.signals = DroneSignals()
self.drone_topics = {}
self.lock = Lock()
self.arm_clients = {}
self.takeoff_clients = {}
self.setpoint_pubs = {}
self.selected_drones = set()
self.latest_data = {}
# 定義需要過濾的模式
self.filtered_modes = ['Mode(0x000000c0)']
# WebSocket 接收器列表
self.ws_receivers = []
# 串口接收器列表
self.serial_receivers = []
# 主题检测定时器
self.create_timer(1.0, self.scan_topics)
def scan_topics(self):
topics = self.get_topic_names_and_types()
drone_pattern = re.compile(r'/MavLinkBus/(s\d+_\d+)/(\w+)')
found_drones = set()
for topic_name, _ in topics:
if match := drone_pattern.match(topic_name):
drone_id, topic_type = match.groups()
found_drones.add(drone_id)
with self.lock:
self.drone_topics.setdefault(drone_id, set()).add(topic_type)
for drone_id in found_drones:
if not hasattr(self, f'drone_{drone_id}_subs'):
self.setup_drone(drone_id)
def setup_drone(self, drone_id):
base_topic = f'/MavLinkBus/{drone_id}'
# Add service clients
self.arm_clients[drone_id] = self.create_client(
CommandBool,
f'{base_topic}/cmd/arming'
)
self.takeoff_clients[drone_id] = self.create_client(
CommandTOL,
f'{base_topic}/cmd/takeoff'
)
# Add setpoint publisher
self.setpoint_pubs[drone_id] = self.create_publisher(
Point,
f'{base_topic}/setpoint_position/local',
10
)
subs = {
'attitude': self.create_subscription(
Imu,
f'{base_topic}/attitude',
lambda msg, did=drone_id: self.attitude_callback(did, msg),
10
),
'battery': self.create_subscription(
BatteryState,
f'{base_topic}/battery',
lambda msg, did=drone_id: self.battery_callback(did, msg),
10
),
'global': self.create_subscription(
NavSatFix,
f'{base_topic}/global_position/global',
lambda msg, did=drone_id: self.gps_callback(did, msg),
10
),
'rel_alt': self.create_subscription(
Float64,
f'{base_topic}/global_position/rel_alt',
lambda msg, did=drone_id: self.altitude_callback(did, msg),
10
),
'local_pose': self.create_subscription(
Point,
f'{base_topic}/local_position/pose',
lambda msg, did=drone_id: self.local_pose_callback(did, msg),
10
),
'local_vel': self.create_subscription(
Vector3,
f'{base_topic}/local_position/velocity',
lambda msg, did=drone_id: self.local_vel_callback(did, msg),
10
),
'loss_rate': self.create_subscription(
Float64,
f'{base_topic}/packet_loss_rate',
lambda msg, did=drone_id: self.loss_rate_callback(did, msg),
10
),
'state': self.create_subscription(
State,
f'{base_topic}/state',
lambda msg, did=drone_id: self.state_callback(did, msg),
10
),
'ping': self.create_subscription(
Float64,
f'{base_topic}/ping',
lambda msg, did=drone_id: self.ping_callback(did, msg),
10
),
'vfr_hud': self.create_subscription(
VfrHud,
f'{base_topic}/vfr_hud',
lambda msg, did=drone_id: self.hud_callback(did, msg),
10
)
}
setattr(self, f'drone_{drone_id}_subs', subs)
async def arm_drone(self, drone_id, arm):
if drone_id not in self.arm_clients:
return False
client = self.arm_clients[drone_id]
if not client.wait_for_service(timeout_sec=1.0):
return False
request = CommandBool.Request()
request.value = arm
future = client.call_async(request)
try:
response = await future
return response.success
except Exception as e:
self.get_logger().error(f'Arm service call failed: {e}')
return False
async def takeoff_drone(self, drone_id, altitude=10.0):
if drone_id not in self.takeoff_clients:
return False
client = self.takeoff_clients[drone_id]
if not client.wait_for_service(timeout_sec=1.0):
return False
request = CommandTOL.Request()
request.altitude = altitude
request.min_pitch = 0.0
request.yaw = 0.0
future = client.call_async(request)
try:
response = await future
return response.success
except Exception as e:
self.get_logger().error(f'Takeoff service call failed: {e}')
return False
def send_setpoint(self, drone_id, x, y, z):
"""Send setpoint position command"""
if drone_id not in self.setpoint_pubs:
return False
msg = Point()
msg.x = float(x)
msg.y = float(y)
msg.z = float(z)
self.setpoint_pubs[drone_id].publish(msg)
return True
def quaternion_to_euler(self, q):
sinr_cosp = 2 * (q.w * q.x + q.y * q.z)
cosr_cosp = 1 - 2 * (q.x**2 + q.y**2)
roll = math.atan2(sinr_cosp, cosr_cosp)
sinp = 2 * (q.w * q.y - q.z * q.x)
pitch = math.asin(sinp) if abs(sinp) < 1 else math.copysign(math.pi/2, sinp)
siny_cosp = 2 * (q.w * q.z + q.x * q.y)
cosy_cosp = 1 - 2 * (q.y**2 + q.z**2)
yaw = math.atan2(siny_cosp, cosy_cosp)
return math.degrees(roll), math.degrees(pitch), math.degrees(yaw)
# callbacks
def attitude_callback(self, drone_id, msg):
if hasattr(msg, 'orientation'):
roll, pitch, yaw = self.quaternion_to_euler(msg.orientation)
self.latest_data[(drone_id, 'attitude')] = {
'roll': roll,
'pitch': pitch,
'yaw': yaw,
'rates': (msg.angular_velocity.x,
msg.angular_velocity.y,
msg.angular_velocity.z)
}
def battery_callback(self, drone_id, msg):
self.latest_data[(drone_id, 'battery')] = {
'voltage': msg.voltage
}
def state_callback(self, drone_id, msg):
mode = msg.mode
if mode in self.filtered_modes:
return
self.latest_data[(drone_id, 'state')] = {
'mode': msg.mode,
'armed': msg.armed
}
def gps_callback(self, drone_id, msg):
self.latest_data[(drone_id, 'gps')] = {
'lat': msg.latitude,
'lon': msg.longitude,
'alt': msg.altitude
}
def local_vel_callback(self, drone_id, msg):
self.latest_data[(drone_id, 'velocity')] = {
'vx': msg.x,
'vy': msg.y,
'vz': msg.z
}
def altitude_callback(self, drone_id, msg):
self.latest_data[(drone_id, 'altitude')] = {
'altitude': msg.data
}
def local_pose_callback(self, drone_id, msg):
self.latest_data[(drone_id, 'local_pose')] = {
'x': msg.x,
'y': msg.y,
'z': msg.z
}
def hud_callback(self, drone_id, msg):
self.latest_data[(drone_id, 'hud')] = {
'airspeed': msg.airspeed,
'groundspeed': msg.groundspeed,
'heading': msg.heading,
'throttle': msg.throttle,
'alt': msg.altitude,
'climb': msg.climb
}
def loss_rate_callback(self, drone_id, msg):
self.latest_data[(drone_id, 'loss_rate')] = {
'loss_rate': msg.data
}
def ping_callback(self, drone_id, msg):
self.latest_data[(drone_id, 'ping')] = {
'ping': msg.data
}
def start_serial_connection(self, port='/dev/ttyUSB0', baudrate=57600):
"""啟動串口 MAVLink 連接"""
connection_name = f"Serial_{port.replace('/', '_')}"
receiver = SerialMavlinkReceiver(port, baudrate, self.signals, connection_name)
receiver.start()
self.serial_receivers.append(receiver)
print(f"Started serial connection on {port} at {baudrate} baud")
return receiver

@ -0,0 +1,378 @@
#!/usr/bin/env python3
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QSizePolicy, QCheckBox)
from PyQt6.QtCore import pyqtSignal
class DronePanel(QWidget):
"""單個無人機面板類別"""
# 定義信號
mode_change_requested = pyqtSignal(str) # drone_id
arm_requested = pyqtSignal(str) # drone_id
takeoff_requested = pyqtSignal(str) # drone_id
setpoint_requested = pyqtSignal(str) # drone_id
selection_changed = pyqtSignal(str, int) # drone_id, state
def __init__(self, drone_id, parent=None):
super().__init__(parent)
self.drone_id = drone_id
self.display_id = 's' + drone_id.split('_')[1]
self._init_ui()
def _init_ui(self):
"""初始化UI"""
self.setObjectName(f"panel_{self.drone_id}")
self.setFixedHeight(140)
self.setStyleSheet("""
background-color: #2A2A2A;
border-radius: 8px;
""")
# 主佈局
main_layout = QHBoxLayout(self)
main_layout.setContentsMargins(8, 8, 8, 8)
main_layout.setSpacing(0)
# 創建內容容器(包含 info 和 control
content_widget = QWidget()
content_widget.setStyleSheet("background-color: #333; border-radius: 6px;")
content_layout = QHBoxLayout(content_widget)
content_layout.setContentsMargins(8, 8, 8, 8)
content_layout.setSpacing(8)
# 左側資訊區域
info_widget = self._create_info_section()
# 右側控制按鈕區域
control_widget = self._create_control_section()
# 將 info 和 control 加入內容容器
content_layout.addWidget(info_widget)
content_layout.addWidget(control_widget)
# 將內容容器加入主佈局
main_layout.addWidget(content_widget)
def _create_info_section(self):
"""創建資訊顯示區域"""
info_widget = QWidget()
info_layout = QVBoxLayout(info_widget)
info_layout.setContentsMargins(0, 0, 0, 0)
info_layout.setSpacing(4)
# 頂部標題欄
header = QWidget()
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(0, 0, 0, 0)
# 勾選框
self.checkbox = QCheckBox()
self.checkbox.setObjectName(f"{self.drone_id}_checkbox")
self.checkbox.setStyleSheet("QCheckBox { color: #DDD; }")
self.checkbox.stateChanged.connect(
lambda state: self.selection_changed.emit(self.drone_id, state)
)
# ID 顯示
id_label = QLabel(self.display_id)
id_label.setStyleSheet("""
font-weight: bold;
font-size: 14px;
color: #7FFFD4;
min-width: 80px;
""")
header_layout.addWidget(self.checkbox)
header_layout.addWidget(id_label)
header_layout.addStretch()
info_layout.addWidget(header)
# 第一行:狀態 (模式 + ARM狀態)
status_row = self._create_status_row()
info_layout.addWidget(status_row)
# 第二行:電池
battery_row = self._create_battery_row()
info_layout.addWidget(battery_row)
# 第三行:位置 + 高度
position_row = self._create_position_row()
info_layout.addWidget(position_row)
# 第四行:航向 + 速度
nav_row = self._create_nav_row()
info_layout.addWidget(nav_row)
return info_widget
def _create_status_row(self):
"""創建狀態行"""
status_row = QWidget()
status_layout = QHBoxLayout(status_row)
status_layout.setContentsMargins(0, 0, 0, 0)
status_title = QLabel("狀態:")
status_title.setStyleSheet("color: #888; min-width: 50px;")
self.mode_label = QLabel("--")
self.mode_label.setObjectName(f"{self.drone_id}_mode")
self.mode_label.setStyleSheet("color: #DDD;")
self.armed_label = QLabel("--")
self.armed_label.setObjectName(f"{self.drone_id}_armed")
self.armed_label.setStyleSheet("color: #DDD;")
status_layout.addWidget(status_title)
status_layout.addWidget(self.mode_label)
status_layout.addWidget(self.armed_label)
status_layout.addStretch()
return status_row
def _create_battery_row(self):
"""創建電池行"""
battery_row = QWidget()
battery_layout = QHBoxLayout(battery_row)
battery_layout.setContentsMargins(0, 0, 0, 0)
battery_title = QLabel("電池:")
battery_title.setStyleSheet("color: #888; min-width: 50px;")
self.battery_label = QLabel("--")
self.battery_label.setObjectName(f"{self.drone_id}_battery")
self.battery_label.setStyleSheet("color: #DDD;")
battery_layout.addWidget(battery_title)
battery_layout.addWidget(self.battery_label)
battery_layout.addStretch()
return battery_row
def _create_position_row(self):
"""創建位置行"""
position_row = QWidget()
position_layout = QHBoxLayout(position_row)
position_layout.setContentsMargins(0, 0, 0, 0)
position_title = QLabel("位置:")
position_title.setStyleSheet("color: #888; min-width: 50px;")
self.local_label = QLabel("--")
self.local_label.setObjectName(f"{self.drone_id}_local")
self.local_label.setStyleSheet("color: #DDD;")
altitude_title = QLabel("高度:")
altitude_title.setStyleSheet("color: #888; margin-left: 10px;")
self.altitude_label = QLabel("--")
self.altitude_label.setObjectName(f"{self.drone_id}_altitude")
self.altitude_label.setStyleSheet("color: #DDD;")
position_layout.addWidget(position_title)
position_layout.addWidget(self.local_label)
position_layout.addWidget(altitude_title)
position_layout.addWidget(self.altitude_label)
position_layout.addStretch()
return position_row
def _create_nav_row(self):
"""創建導航行"""
nav_row = QWidget()
nav_layout = QHBoxLayout(nav_row)
nav_layout.setContentsMargins(0, 0, 0, 0)
heading_title = QLabel("航向:")
heading_title.setStyleSheet("color: #888; min-width: 50px;")
self.heading_label = QLabel("--")
self.heading_label.setObjectName(f"{self.drone_id}_heading")
self.heading_label.setStyleSheet("color: #DDD;")
speed_title = QLabel("速度:")
speed_title.setStyleSheet("color: #888; margin-left: 10px;")
self.groundspeed_label = QLabel("--")
self.groundspeed_label.setObjectName(f"{self.drone_id}_groundspeed")
self.groundspeed_label.setStyleSheet("color: #DDD;")
nav_layout.addWidget(heading_title)
nav_layout.addWidget(self.heading_label)
nav_layout.addWidget(speed_title)
nav_layout.addWidget(self.groundspeed_label)
nav_layout.addStretch()
return nav_row
def _create_control_section(self):
"""創建控制按鈕區域"""
control_widget = QWidget()
control_layout = QVBoxLayout(control_widget)
control_layout.setContentsMargins(0, 0, 0, 0)
control_layout.setSpacing(6)
control_widget.setFixedWidth(80)
btn_style = """
QPushButton {
background-color: #444;
color: #DDD;
border: none;
border-radius: 4px;
font-size: 11px;
}
QPushButton:hover {
background-color: #555;
}
"""
# 模式切換按鈕
mode_btn = QPushButton("切換模式")
mode_btn.setStyleSheet(btn_style)
mode_btn.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
mode_btn.clicked.connect(lambda: self.mode_change_requested.emit(self.drone_id))
# 解鎖按鈕
arm_btn = QPushButton("解鎖")
arm_btn.setStyleSheet(btn_style)
arm_btn.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
arm_btn.clicked.connect(lambda: self.arm_requested.emit(self.drone_id))
# 起飛按鈕
takeoff_btn = QPushButton("起飛")
takeoff_btn.setStyleSheet(btn_style)
takeoff_btn.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
takeoff_btn.clicked.connect(lambda: self.takeoff_requested.emit(self.drone_id))
# Setpoint 按鈕
setpoint_btn = QPushButton("Setpoint")
setpoint_btn.setStyleSheet(btn_style)
setpoint_btn.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
setpoint_btn.clicked.connect(lambda: self.setpoint_requested.emit(self.drone_id))
control_layout.addWidget(mode_btn)
control_layout.addWidget(arm_btn)
control_layout.addWidget(takeoff_btn)
control_layout.addWidget(setpoint_btn)
return control_widget
def update_field(self, field, text, color=None):
"""更新指定欄位的值"""
label = self.findChild(QLabel, f"{self.drone_id}_{field}")
if label and label.text() != text:
label.setText(text)
if color:
label.setStyleSheet(f"color: {color};")
def get_checkbox(self):
"""獲取勾選框"""
return self.checkbox
def set_checked(self, checked):
"""設置勾選狀態"""
self.checkbox.setChecked(checked)
def is_checked(self):
"""獲取勾選狀態"""
return self.checkbox.isChecked()
class SocketGroupPanel(QWidget):
# 定義信號
group_selection_changed = pyqtSignal(str, int) # socket_id, state
def __init__(self, socket_id, color='#AAAAAA', parent=None):
super().__init__(parent)
self.socket_id = socket_id
self.color = color
self._init_ui()
def _init_ui(self):
"""初始化UI"""
self.setObjectName(f"socket_group_{self.socket_id}")
self.setStyleSheet("""
background-color: #1E1E1E;
border-radius: 12px;
""")
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(6)
# Socket 分組標題行 - 包含勾選框
title_row = QWidget()
title_layout = QHBoxLayout(title_row)
title_layout.setContentsMargins(0, 0, 0, 0)
# 分組勾選框
self.group_checkbox = QCheckBox()
self.group_checkbox.setObjectName(f"socket_{self.socket_id}_checkbox")
self.group_checkbox.setStyleSheet(f"""
QCheckBox {{ color: #DDD; }}
QCheckBox::indicator {{
width: 14px;
height: 14px;
border: 2px solid #888888;
border-radius: 3px;
background: transparent;
}}
QCheckBox::indicator:checked {{
background-color: {self.color};
border: 2px solid #888888;
}}
QCheckBox::indicator:indeterminate {{
background-color: #666;
border: 2px solid #888888;
}}
""")
self.group_checkbox.stateChanged.connect(
lambda state: self.group_selection_changed.emit(self.socket_id, state)
)
# Socket 分組標題
title_label = QLabel(f"Socket {self.socket_id}")
title_label.setStyleSheet(f"""
font-weight: bold;
font-size: 16px;
color: {self.color};
margin-bottom: 8px;
padding: 4px 8px;
border-radius: 6px;
""")
title_layout.addWidget(self.group_checkbox)
title_layout.addWidget(title_label)
title_layout.addStretch()
layout.addWidget(title_row)
# 創建子容器用於放置該 socket 下的所有無人機面板
self.drones_container = QWidget()
self.drones_layout = QVBoxLayout(self.drones_container)
self.drones_layout.setContentsMargins(0, 0, 0, 0)
self.drones_layout.setSpacing(4)
layout.addWidget(self.drones_container)
def add_drone_panel(self, panel):
"""添加無人機面板到分組"""
self.drones_layout.addWidget(panel)
def clear_drones(self):
"""清空所有無人機面板"""
while self.drones_layout.count():
item = self.drones_layout.takeAt(0)
if item.widget():
item.widget().setParent(None)
def get_checkbox(self):
"""獲取分組勾選框"""
return self.group_checkbox
def set_checked(self, checked):
"""設置分組勾選狀態"""
self.group_checkbox.setChecked(checked)
def set_check_state(self, state):
"""設置分組勾選狀態(支持半選)"""
self.group_checkbox.setCheckState(state)

File diff suppressed because it is too large Load Diff

@ -0,0 +1,292 @@
#!/usr/bin/env python3
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtCore import QTimer, pyqtSignal, QObject, pyqtSlot
from PyQt6.QtWebChannel import QWebChannel
class DroneMap:
"""無人機地圖類別 - 負責管理 Leaflet 地圖顯示"""
def __init__(self):
"""初始化地圖"""
self.map_view = QWebEngineView()
self.map_loaded = False
self.pending_map_updates = {}
# 創建橋接對象
self.bridge = MapBridge()
# 設置 QWebChannel
self.channel = QWebChannel()
self.channel.registerObject('bridge', self.bridge)
self.map_view.page().setWebChannel(self.channel)
# 設置地圖 HTML
inline_html = '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-rotatedmarker/leaflet.rotatedMarker.js"></script>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
html, body, #map { height: 100%; margin: 0; }
.map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
}
.control-button {
padding: 8px 12px;
background-color: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.control-button:hover {
background-color: #d32f2f;
}
</style>
</head>
<body>
<div id="map"></div>
<div class="map-controls">
<button class="control-button" onclick="clearAllTrajectories()">清除軌跡</button>
</div>
<script>
var bridge;
new QWebChannel(qt.webChannelTransport, function(channel) {
bridge = channel.objects.bridge;
});
var map = L.map('map').setView([0, 0], 19);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// 地圖點擊事件
map.on('click', function(e) {
if (bridge) {
bridge.emitGpsSignal(e.latlng.lat, e.latlng.lng);
console.log('點擊位置:', e.latlng.lat, e.latlng.lng);
}
});
var arrowIcon = L.icon({
iconUrl: 'https://cdn-icons-png.flaticon.com/512/399/399308.png',
iconSize: [40, 40],
iconAnchor: [20, 20]
});
function getColorBySocketId(id) {
if (id.startsWith("s0_")) return "#00BFFF"; // 天藍色
if (id.startsWith("s1_")) return "#FFD700"; // 金色
if (id.startsWith("s2_")) return "#FF6969"; // 淺紅色
if (id.startsWith("s3_")) return "#FF69B4"; // 熱粉紅
if (id.startsWith("s4_")) return "#00FA9A"; // 中春綠
if (id.startsWith("s5_")) return "#9370DB"; // 中紫色 (串口)
if (id.startsWith("s6_")) return "#FFA500"; // 橙色
if (id.startsWith("s7_")) return "#20B2AA"; // 淺海綠
if (id.startsWith("s8_")) return "#7CFC00"; // 草綠色
if (id.startsWith("s9_")) return "#FF8C00"; // 深橙色
return "#AAAAAA"; // 灰色 (預設)
}
function createIdIcon(id) {
const color = getColorBySocketId(id);
const sysid = id.split('_')[1];
return L.divIcon({
className: 'drone-id',
html: `<div style="
position: relative;
left: 2px;
background-color: ${color};
color: black;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 10px;
">${sysid}</div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
});
}
var markers = {};
var idLabels = {};
var trajectories = {};
var trajectoryLines = {};
var focusedId = null;
var initialized = false;
var trajectoryGroup = L.layerGroup().addTo(map);
function initTrajectory(id) {
if (!trajectories[id]) {
trajectories[id] = [];
const color = getColorBySocketId(id);
trajectoryLines[id] = L.polyline([], {
color: color,
weight: 3,
opacity: 0.7,
smoothFactor: 1
}).addTo(trajectoryGroup);
}
}
function addTrajectoryPoint(id, lat, lon) {
initTrajectory(id);
const point = [lat, lon];
trajectories[id].push(point);
if (trajectories[id].length > 1000) {
trajectories[id].shift();
}
trajectoryLines[id].setLatLngs([...trajectories[id]]);
}
function focusOn(id) {
if (!markers[id]) return;
focusedId = id;
var latlng = markers[id].getLatLng();
map.flyTo(latlng, map.getZoom());
}
setInterval(() => {
if (focusedId && markers[focusedId]) {
var latlng = markers[focusedId].getLatLng();
map.panTo(latlng);
}
}, 1000);
function updateDrone(lat, lon, id, heading) {
if (markers[id]) {
const lastPos = markers[id].getLatLng();
const distance = lastPos.distanceTo([lat, lon]);
if (distance > 1) {
addTrajectoryPoint(id, lat, lon);
}
markers[id]
.setLatLng([lat, lon])
.setRotationAngle(heading);
idLabels[id].setLatLng([lat, lon]);
} else {
initTrajectory(id);
addTrajectoryPoint(id, lat, lon);
markers[id] = L.marker([lat, lon], {
icon: arrowIcon,
rotationAngle: heading,
rotationOrigin: 'center'
})
.on('click', function () {
focusOn(id);
})
.addTo(map);
idLabels[id] = L.marker([lat, lon], {
icon: createIdIcon(id),
zIndexOffset: 1000
})
.on('click', function() {
focusOn(id);
})
.addTo(map);
if (!initialized || id < focusedId) {
focusOn(id);
initialized = true;
}
}
}
function clearAllTrajectories() {
trajectories = {};
Object.values(trajectoryLines).forEach(line => {
trajectoryGroup.removeLayer(line);
});
trajectoryLines = {};
console.log('所有軌跡已清除');
}
</script>
</body>
</html>
'''
self.map_view.setHtml(inline_html)
self.map_view.loadFinished.connect(self._on_map_loaded)
# 設置地圖更新計時器
self.map_update_timer = QTimer()
self.map_update_timer.timeout.connect(self.update_map_positions)
self.map_update_timer.start(200) # 每 200ms 更新一次
def _on_map_loaded(self, ok: bool):
"""地圖加載完成回調"""
if ok:
self.map_loaded = True
else:
print("⚠️ 地圖加載失敗")
def update_drone_position(self, drone_id, lat, lon, heading):
"""更新無人機位置(加入待處理隊列)"""
self.pending_map_updates[drone_id] = (lat, lon, heading)
def update_map_positions(self):
"""批量更新地圖上的無人機位置"""
if not self.map_loaded or not self.pending_map_updates:
return
# 批量執行所有待更新的位置
js_commands = []
for drone_id, (lat, lon, heading) in self.pending_map_updates.items():
js_commands.append(f"updateDrone({lat:.6f}, {lon:.6f}, '{drone_id}', {heading:.1f});")
if js_commands:
combined_js = "\n".join(js_commands)
self.map_view.page().runJavaScript(combined_js)
# 清空待更新緩存
self.pending_map_updates.clear()
def clear_trajectories(self):
"""清除所有軌跡"""
if self.map_loaded:
self.map_view.page().runJavaScript("clearAllTrajectories();")
def focus_on_drone(self, drone_id):
"""聚焦到指定無人機"""
if self.map_loaded:
self.map_view.page().runJavaScript(f"focusOn('{drone_id}');")
def get_widget(self):
"""獲取地圖 widget"""
return self.map_view
def get_gps_signal(self):
"""獲取 GPS 信號"""
return self.bridge.gps_signal
class MapBridge(QObject):
"""JavaScript 和 Python 之間的橋接類"""
gps_signal = pyqtSignal(float, float) # lat, lon
def __init__(self):
super().__init__()
@pyqtSlot(float, float)
def emitGpsSignal(self, lat, lon):
"""供 JavaScript 調用的方法"""
self.gps_signal.emit(lat, lon)

@ -21,13 +21,15 @@ class MAVLinkWorker(QThread):
param_signal = pyqtSignal(str, int) param_signal = pyqtSignal(str, int)
kbps_signal = pyqtSignal(str, float) kbps_signal = pyqtSignal(str, float)
def __init__(self, connection_string="udp:127.0.0.1:14550", usb='/dev/ttyUSB0'): def __init__(self, connection_string="udp:127.0.0.1:14560 ", usb='/dev/ttyUSB0', acm ='/dev/ttyACM0'):
super().__init__() super().__init__()
self.namespaces = ['UAV1', 'UAV2', 'UAV3'] self.sysids = [1, 5, 10]
self.connection = mavutil.mavlink_connection(usb, baud=57600) self.namespaces = {}
self.namespaces = [f"UAV{sysid}" for sysid in self.sysids]
self.connection = mavutil.mavlink_connection(connection_string, baud=115200)
self.connection.wait_heartbeat() self.connection.wait_heartbeat()
for sysid in self.namespaces: for sysid in self.sysids:
sysid = int(sysid.replace('UAV', ''))
self.set_sr_params(sysid) self.set_sr_params(sysid)
self.running = True self.running = True
@ -42,8 +44,8 @@ class MAVLinkWorker(QThread):
self.total_bytes_received = {} self.total_bytes_received = {}
self.throughput_KBps = {} self.throughput_KBps = {}
for namespace in self.namespaces: for sysid in self.sysids:
self.request_param(namespace, "SR1_EXTRA1") self.request_param(sysid, "SR1_EXTRA1")
self.connection.mav.timesync_send( self.connection.mav.timesync_send(
0, #tc1 0, #tc1
int(time.time() * 1e9) #ts1 int(time.time() * 1e9) #ts1
@ -52,7 +54,7 @@ class MAVLinkWorker(QThread):
def run(self): def run(self):
while self.running: while self.running:
try: try:
msg = self.connection.recv_msg() msg = self.connection.recv_msg() # Debugging line to see received messages
current_time = time.time() current_time = time.time()
if not msg: if not msg:
continue continue
@ -64,7 +66,6 @@ class MAVLinkWorker(QThread):
if msg_type =="BAD_DATA": if msg_type =="BAD_DATA":
continue continue
if sysid not in self.total_bytes_received: if sysid not in self.total_bytes_received:
self.total_bytes_received[sysid] = 0 self.total_bytes_received[sysid] = 0
self.throughput_KBps[sysid] = 0 self.throughput_KBps[sysid] = 0
@ -162,7 +163,7 @@ class MAVLinkWorker(QThread):
elif msg_type == 'PARAM_VALUE': elif msg_type == 'PARAM_VALUE':
param_name = "SR1_EXTRA1" param_name = "SR1_EXTRA1"
if msg.param_id == param_name: if msg.param_id == param_name:
self.param_signal.emit(namespace, msg.param_value) self.param_signal.emit(namespace, int(msg.param_value))
except Exception as e: except Exception as e:
print(f"Error reading message: {e}") print(f"Error reading message: {e}")
@ -171,8 +172,11 @@ class MAVLinkWorker(QThread):
self.running = False self.running = False
self.connection.close() self.connection.close()
def get_sysid(self, namespace):
return int(namespace.replace('UAV', ''))
def set_mode(self, namespace, mode): def set_mode(self, namespace, mode):
sysid = int(namespace.replace('UAV', '')) sysid = self.get_sysid(namespace)
if mode == 'STABILIZE': if mode == 'STABILIZE':
mode = 0 mode = 0
elif mode == 'AUTO': elif mode == 'AUTO':
@ -188,7 +192,7 @@ class MAVLinkWorker(QThread):
) )
def arm(self, namespace, arm): def arm(self, namespace, arm):
sysid = int(namespace.replace('UAV', '')) sysid = self.get_sysid(namespace)
self.connection.mav.command_long_send( self.connection.mav.command_long_send(
sysid, sysid,
1, # Component ID 1, # Component ID
@ -199,7 +203,7 @@ class MAVLinkWorker(QThread):
) )
def takeoff(self, namespace, altitude): def takeoff(self, namespace, altitude):
sysid = int(namespace.replace('UAV', '')) sysid = self.get_sysid(namespace)
self.connection.mav.command_long_send( self.connection.mav.command_long_send(
sysid, sysid,
1, # Component ID 1, # Component ID
@ -210,7 +214,7 @@ class MAVLinkWorker(QThread):
) )
def land(self, namespace): def land(self, namespace):
sysid = int(namespace.replace('UAV', '')) sysid = self.get_sysid(namespace)
self.connection.mav.command_long_send( self.connection.mav.command_long_send(
sysid, sysid,
1, # Component ID 1, # Component ID
@ -221,7 +225,7 @@ class MAVLinkWorker(QThread):
) )
def set_local_position(self, namespace, x, y, z): def set_local_position(self, namespace, x, y, z):
sysid = int(namespace.replace('UAV', '')) sysid = self.get_sysid(namespace)
self.connection.mav.set_position_target_local_ned_send( self.connection.mav.set_position_target_local_ned_send(
0, sysid, 1, # time_boot_ms, sysid, compid 0, sysid, 1, # time_boot_ms, sysid, compid
mavutil.mavlink.MAV_FRAME_LOCAL_NED, # Frame mavutil.mavlink.MAV_FRAME_LOCAL_NED, # Frame
@ -233,7 +237,7 @@ class MAVLinkWorker(QThread):
) )
def reboot_drone(self, namespace): def reboot_drone(self, namespace):
sysid = int(namespace.replace('UAV', '')) sysid = self.get_sysid(namespace)
self.connection.mav.command_long_send( self.connection.mav.command_long_send(
sysid, sysid,
1, # target_component (一般為1) 1, # target_component (一般為1)
@ -244,7 +248,7 @@ class MAVLinkWorker(QThread):
) )
def set_param(self, namespace, param_name, value): def set_param(self, namespace, param_name, value):
sysid = int(namespace.replace('UAV', '')) sysid = self.get_sysid(namespace)
try: try:
self.connection.mav.param_set_send( self.connection.mav.param_set_send(
sysid, sysid,
@ -256,8 +260,7 @@ class MAVLinkWorker(QThread):
except Exception as e: except Exception as e:
print(f"Failed to set parameter {param_name}: {e}") print(f"Failed to set parameter {param_name}: {e}")
def request_param(self, namespace, param_name): def request_param(self, sysid, param_name):
sysid = int(namespace.replace('UAV', ''))
try: try:
self.connection.mav.param_request_read_send( self.connection.mav.param_request_read_send(
sysid, # 系統 ID sysid, # 系統 ID
@ -268,43 +271,57 @@ class MAVLinkWorker(QThread):
except Exception as e: except Exception as e:
print(f"Failed to set parameter {param_name}: {e}") print(f"Failed to set parameter {param_name}: {e}")
'''
def set_sr_params(self, sysid): def set_sr_params(self, sysid):
""" 直接設置 MAVLink 訊息頻率 """ """ 直接設置 MAVLink 訊息頻率 """
freqs = [0, 2, 4] freqs = [0, 1, 4]
params = { params = {
"SR1_ADSB": freqs[0], "SR1_ADSB": freqs[0],
"SR1_EXT_STAT": freqs[1], "SR1_EXT_STAT": freqs[1],
"SR1_EXTRA1": freqs[2], "SR1_EXTRA1": freqs[2],
"SR1_EXTRA2": freqs[2], "SR1_EXTRA2": freqs[1],
"SR1_EXTRA3": freqs[1], "SR1_EXTRA3": freqs[1],
"SR1_POSITION": freqs[1], "SR1_POSITION": freqs[2],
"SR1_RAW_SENS": freqs[1], "SR1_RAW_SENS": freqs[1],
"SR1_RC_CHAN": freqs[1] "SR1_RC_CHAN": freqs[1]
} }
for param, value in params.items(): for param, value in params.items():
self.set_param(f"UAV{sysid}", param, value) self.set_param(f"UAV{sysid}", param, value)
'''
'''
def set_sr_params(self, sysid):
""" 直接設置 MAVLink 訊息頻率 """
params = {
"SERIAL1_BAUD": 38
}
for param, value in params.items():
self.set_param(f"UAV{sysid}", param, value)
def set_sr_params(self, sysid): def set_sr_params(self, sysid):
""" 直接設置 MAVLink 訊息頻率 """ """ 直接設置 MAVLink 訊息頻率 """
freqs = [0, 1, 1] freqs = [0, 3, 3]
params = { params = {
"SR1_ADSB": freqs[0], "SR1_ADSB": freqs[0],
"SR1_EXT_STAT": freqs[0], "SR1_EXT_STAT": freqs[1],
"SR1_EXTRA1": freqs[2], "SR1_EXTRA1": freqs[2],
"SR1_EXTRA2": freqs[2], "SR1_EXTRA2": freqs[2],
"SR1_EXTRA3": freqs[0], "SR1_EXTRA3": freqs[1],
"SR1_POSITION": freqs[1], "SR1_POSITION": freqs[1],
"SR1_RAW_SENS": freqs[1], "SR1_RAW_SENS": freqs[1],
"SR1_RC_CHAN": freqs[0] "SR1_RC_CHAN": freqs[1]
} }
for param, value in params.items(): for param, value in params.items():
self.set_param(f"UAV{sysid}", param, value) self.set_param(f"UAV{sysid}", param, value)
'''
def init_drone(self, namespace):
sysid = self.get_sysid(namespace)
self.set_local_position(sysid, 0, 0, 0)
class DroneControlApp(QMainWindow): class DroneControlApp(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.workers = MAVLinkWorker() self.workers = MAVLinkWorker()
self.sysids = self.workers.sysids
self.namespaces = self.workers.namespaces self.namespaces = self.workers.namespaces
self.initUI() self.initUI()
@ -438,6 +455,15 @@ class DroneControlApp(QMainWindow):
self.rebootButton.clicked.connect(self.reboot_drone) self.rebootButton.clicked.connect(self.reboot_drone)
main_layout.addWidget(self.rebootButton) main_layout.addWidget(self.rebootButton)
mission_layout = QHBoxLayout()
self.initButton = QPushButton("重設位置")
self.initButton.clicked.connect(self.init_drone)
self.startButton = QPushButton("開始任務")
self.startButton.clicked.connect(self.start_mission)
mission_layout.addWidget(self.initButton)
mission_layout.addWidget(self.startButton)
main_layout.addLayout(mission_layout)
# 設置主佈局 # 設置主佈局
central_widget = QWidget(self) central_widget = QWidget(self)
central_widget.setLayout(main_layout) central_widget.setLayout(main_layout)
@ -563,6 +589,24 @@ class DroneControlApp(QMainWindow):
except ValueError: except ValueError:
QtWidgets.QMessageBox.warning(self, "錯誤", "請輸入有效的數值!") QtWidgets.QMessageBox.warning(self, "錯誤", "請輸入有效的數值!")
def init_drone(self):
try:
for namespace in self.namespaces:
if self.uav_checkboxes[namespace].isChecked():
self.workers.init_drone(namespace)
print(f"重設位置:{namespace}")
except Exception as e:
QtWidgets.QMessageBox.warning(self, "錯誤", f"重設位置失敗:{e}")
def start_mission(self):
try:
for namespace in self.namespaces:
if self.uav_checkboxes[namespace].isChecked():
self.workers.start_mission(namespace)
print(f"開始任務:{namespace}")
except Exception as e:
QtWidgets.QMessageBox.warning(self, "錯誤", f"開始任務失敗:{e}")
def main(): def main():
app = QtWidgets.QApplication(sys.argv) app = QtWidgets.QApplication(sys.argv)
window = DroneControlApp() window = DroneControlApp()

@ -1,619 +0,0 @@
import sys
import time
import math
import serial
import struct
from PyQt5 import QtWidgets
from PyQt5.QtCore import QThread, pyqtSignal
from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QCheckBox, QWidget, QMainWindow, QComboBox
from pymavlink import mavutil
from pymavlink.dialects.v20 import ardupilotmega as mavlink2
class PacketCapture:
def __init__(self):
self.data = bytearray()
def write(self, b):
self.data.extend(b)
return len(b)
class MAVLinkWorker(QThread):
state_signal = pyqtSignal(str, str)
battery_signal = pyqtSignal(str, float)
gps_signal = pyqtSignal(str, float, float)
gps_status_signal = pyqtSignal(str, int, int)
local_position_signal = pyqtSignal(str, float, float, float)
imu_signal = pyqtSignal(str, float)
hdg_signal = pyqtSignal(str, float)
groundspeed_signal = pyqtSignal(str, float)
ping_signal = pyqtSignal(str, float)
loss_signal = pyqtSignal(str, float)
frequency_signal = pyqtSignal(str, float)
param_signal = pyqtSignal(str, int)
kbps_signal = pyqtSignal(str, float)
def __init__(self):
super().__init__()
self.namespaces = ['UAV1', 'UAV2', 'UAV3']
self.connection = mavutil.mavlink_connection('/dev/ttyUSB0', baud=57600)
self.connection.wait_heartbeat()
for sysid in self.namespaces:
sysid = int(sysid.replace('UAV', ''))
self.set_sr_params(sysid)
self.running = True
# 用於計算頻率丟包
self.message_count = {}
self.frequency = {}
self.start_time = {}
self.seq_numbers = {}
self.packet_loss_count = {}
self.total_packet_count = {}
self.loss_percentage = {}
self.total_bytes_received = {}
self.throughput_KBps = {}
for namespace in self.namespaces:
self.request_param(namespace, "SR1_EXTRA1")
self.connection.mav.timesync_send(
0, #tc1
int(time.time() * 1e9) #ts1
)
def run(self):
while self.running:
try:
msg = self.connection.recv_msg()
current_time = time.time()
if not msg:
continue
sysid = msg.get_srcSystem()
if sysid == 0:
continue
namespace = f"UAV{sysid}"
msg_type = msg.get_type()
if msg_type =="BAD_DATA":
continue
if sysid not in self.total_bytes_received:
self.total_bytes_received[sysid] = 0
self.throughput_KBps[sysid] = 0
# 計算訊息大小
msg_bytes = msg.get_msgbuf() # 取得封包的 bytes
if msg_bytes:
self.total_bytes_received[sysid] += len(msg_bytes)
# Packet loss calculation
if sysid not in self.seq_numbers:
self.seq_numbers[sysid] = msg.get_seq() # Initialize sequence number tracking
self.packet_loss_count[sysid] = 0
self.total_packet_count[sysid] = 1
else:
current_seq = msg.get_seq()
expected_seq = (self.seq_numbers[sysid] + 1) % 256
self.total_packet_count[sysid] += 1
if current_seq != expected_seq: # Packet(s) lost
lost_packets = (current_seq - expected_seq) % 256
self.packet_loss_count[sysid] += lost_packets
self.total_packet_count[sysid] += lost_packets
self.seq_numbers[sysid] = current_seq
self.loss_percentage[sysid] = (self.packet_loss_count[sysid] / self.total_packet_count[sysid]) * 100
self.loss_signal.emit(namespace, self.loss_percentage[sysid])
# Frequency calculation
if sysid not in self.message_count:
self.message_count[sysid] = 0
self.start_time[sysid] = current_time
self.message_count[sysid] += 1
# 每隔一段時間更新
elapsed_time = current_time - self.start_time[sysid]
if elapsed_time >= 1:
# 每秒頻率
self.frequency[sysid] = self.message_count[sysid] / elapsed_time
self.frequency_signal.emit(namespace, self.frequency[sysid])
# 發送 timesync request
self.connection.mav.timesync_send(
0, #tc1
int(current_time * 1e9) #ts1
)
#吞吐量
self.throughput_KBps[sysid] = (self.total_bytes_received[sysid] / (1024)) / elapsed_time
self.kbps_signal.emit(namespace, self.throughput_KBps[sysid])
self.start_time[sysid] = current_time
self.message_count[sysid] = 0
self.total_bytes_received[sysid] = 0
if msg_type == "HEARTBEAT":
mode = mavutil.mode_string_v10(msg)
self.state_signal.emit(namespace, mode)
elif msg_type == "BATTERY_STATUS":
voltage = msg.voltages[0] / 1000
self.battery_signal.emit(namespace, voltage)
elif msg_type == "GLOBAL_POSITION_INT":
latitude = msg.lat / 1e7
longitude = msg.lon / 1e7
self.gps_signal.emit(namespace, latitude, longitude)
elif msg_type == "GPS_RAW_INT":
fix_type = msg.fix_type
satellites_visible = msg.satellites_visible
self.gps_status_signal.emit(namespace, fix_type, satellites_visible)
elif msg_type == "LOCAL_POSITION_NED":
x = msg.y
y = msg.x
z = -msg.z
self.local_position_signal.emit(namespace, x, y, z)
elif msg_type == "ATTITUDE":
pitch = math.degrees(msg.pitch)
self.imu_signal.emit(namespace, pitch)
elif msg_type == "VFR_HUD":
groundspeed = msg.groundspeed
heading = msg.heading
self.hdg_signal.emit(namespace, heading)
self.groundspeed_signal.emit(namespace, groundspeed)
elif msg_type == "TIMESYNC":
round_trip_time = (int(current_time * 1e9) - msg.ts1) / 1e6
if(round_trip_time<1e6):
self.ping_signal.emit(namespace, round_trip_time)
elif msg_type == 'PARAM_VALUE':
param_name = "SR1_EXTRA1"
if msg.param_id == param_name:
self.param_signal.emit(namespace, msg.param_value)
except Exception as e:
print(f"Error reading message: {e}")
def stop(self):
self.running = False
self.connection.close()
def build_api_tx_frame(self, data: bytes, frame_id=0x01):
frame_type = 0x10
dest_addr64 = b'\x00\x00\x00\x00\x00\x00\xFF\xFF' # Broadcast
dest_addr16 = b'\xFF\xFE'
broadcast_radius = 0x00
options = 0x00
frame = struct.pack(">B", frame_type) + struct.pack(">B", frame_id)
frame += dest_addr64 + dest_addr16
frame += struct.pack(">BB", broadcast_radius, options) + data
checksum = 0xFF - (sum(frame) & 0xFF)
return b'\x7E' + struct.pack(">H", len(frame)) + frame + struct.pack("B", checksum)
def send_command(self, msg):
# Create the packet and send via serial port
PORT = "/dev/ttyUSB0"
BAUD = 57600
ser = serial.Serial(PORT, BAUD)
capture = PacketCapture()
mav = mavlink2.MAVLink(capture, srcSystem=1, srcComponent=1)
mav.version = 2
mav.send(msg)
api_frame = self.build_api_tx_frame(capture.data)
ser.write(api_frame)
print("RAW HEX:", capture.data.hex())
def set_mode(self, namespace, mode):
sysid = int(namespace.replace('UAV', ''))
if mode == 'STABILIZE':
mode = 0
elif mode == 'AUTO':
mode = 3
elif mode == 'GUIDED':
mode = 4
elif mode == 'LOITER':
mode = 5
msg = self.connection.mav.command_long_encode(
target_system=sysid,
target_component=1,
command=mavutil.mavlink.MAV_CMD_DO_SET_MODE,
confirmation=0,
param1=1, # Custom mode enabled
param2=mode,
param3=0, param4=0, param5=0, param6=0, param7=0
)
self.send_command(msg)
def arm(self, namespace, arm):
sysid = int(namespace.replace('UAV', ''))
msg = self.connection.mav.command_long_encode(
target_system=sysid,
target_component=1,
command=mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
confirmation=0,
param1=1 if arm else 0, # 1 to arm, 0 to disarm
param2=0, param3=0, param4=0, param5=0, param6=0, param7=0
)
self.send_command(msg)
def takeoff(self, namespace, altitude):
sysid = int(namespace.replace('UAV', ''))
msg = self.connection.mav.command_long_encode(
target_system=sysid,
target_component=1,
command=mavutil.mavlink.MAV_CMD_NAV_TAKEOFF,
confirmation=0,
param1=0, param2=0, param3=0, param4=0, param5=0, param6=0,
param7=altitude
)
self.send_command(msg)
def land(self, namespace):
sysid = int(namespace.replace('UAV', ''))
msg = self.connection.mav.command_long_encode(
target_system=sysid,
target_component=1,
command=mavutil.mavlink.MAV_CMD_NAV_LAND,
confirmation=0,
param1=0, param2=0, param3=0, param4=0, param5=0, param6=0,
param7=0
)
self.send_command(msg)
def set_local_position(self, namespace, x, y, z):
sysid = int(namespace.replace('UAV', ''))
msg = self.connection.mav.set_position_target_local_ned_encode(
0, sysid, 1, # time_boot_ms, sysid, compid
mavutil.mavlink.MAV_FRAME_LOCAL_NED, # Frame
0b110111111000, # Mask
y, x, -z, # Position
0, 0, 0, # Velocity
0, 0, 0, # Acceleration
0, 0 # Yaw, yaw_rate
)
self.send_command(msg)
def reboot_drone(self, namespace):
sysid = int(namespace.replace('UAV', ''))
self.connection.mav.command_long_send(
sysid,
1, # target_component (一般為1)
mavutil.mavlink.MAV_CMD_PREFLIGHT_REBOOT_SHUTDOWN, # Reboot 命令
0, # confirmation
1, # 第一個參數 (1 = reboot0 = 無操作2 = 關機)
0, 0, 0, 0, 0, 0 # 其餘參數保留
)
def set_param(self, namespace, param_name, value):
sysid = int(namespace.replace('UAV', ''))
try:
self.connection.mav.param_set_send(
sysid,
1,
param_name.encode('utf-8'),
float(value),
mavutil.mavlink.MAV_PARAM_TYPE_REAL32
)
except Exception as e:
print(f"Failed to set parameter {param_name}: {e}")
def request_param(self, namespace, param_name):
sysid = int(namespace.replace('UAV', ''))
try:
self.connection.mav.param_request_read_send(
sysid, # 系統 ID
1, # 組件 ID
param_name.encode('utf-8'), # 參數名稱
-1 # 參數索引,-1 表示根據名稱請求
)
except Exception as e:
print(f"Failed to set parameter {param_name}: {e}")
'''
def set_sr_params(self, sysid):
""" 直接設置 MAVLink 訊息頻率 """
freqs = [0, 2, 4]
params = {
"SR1_ADSB": freqs[0],
"SR1_EXT_STAT": freqs[1],
"SR1_EXTRA1": freqs[2],
"SR1_EXTRA2": freqs[2],
"SR1_EXTRA3": freqs[1],
"SR1_POSITION": freqs[1],
"SR1_RAW_SENS": freqs[1],
"SR1_RC_CHAN": freqs[1]
}
for param, value in params.items():
self.set_param(f"UAV{sysid}", param, value)
'''
def set_sr_params(self, sysid):
""" 直接設置 MAVLink 訊息頻率 """
freqs = [0, 1, 1]
params = {
"SR1_ADSB": freqs[0],
"SR1_EXT_STAT": freqs[0],
"SR1_EXTRA1": freqs[2],
"SR1_EXTRA2": freqs[2],
"SR1_EXTRA3": freqs[0],
"SR1_POSITION": freqs[1],
"SR1_RAW_SENS": freqs[1],
"SR1_RC_CHAN": freqs[0]
}
for param, value in params.items():
self.set_param(f"UAV{sysid}", param, value)
class DroneControlApp(QMainWindow):
def __init__(self):
super().__init__()
self.workers = MAVLinkWorker()
self.namespaces = self.workers.namespaces
self.initUI()
# Connect signals to update the UI
self.workers.state_signal.connect(self.update_state)
self.workers.battery_signal.connect(self.update_battery)
self.workers.gps_signal.connect(self.update_gps)
self.workers.gps_status_signal.connect(self.update_gps_status)
self.workers.local_position_signal.connect(self.update_local_position)
self.workers.imu_signal.connect(self.update_imu)
self.workers.hdg_signal.connect(self.update_hdg)
self.workers.groundspeed_signal.connect(self.update_groundspeed)
self.workers.ping_signal.connect(self.update_ping)
self.workers.loss_signal.connect(self.update_loss)
self.workers.frequency_signal.connect(self.update_frequency)
self.workers.param_signal.connect(self.update_param)
self.workers.kbps_signal.connect(self.update_kbps)
self.workers.start()
def initUI(self):
self.setWindowTitle("多無人機控制介面")
self.setGeometry(100, 100, 800, 600)
# 主佈局
main_layout = QVBoxLayout()
# 無人機選擇區域
uav_layout = QHBoxLayout()
self.uav_checkboxes = {}
for namespace in self.namespaces:
checkbox = QCheckBox(namespace)
checkbox.setChecked(True) # 預設勾選
self.uav_checkboxes[namespace] = checkbox
uav_layout.addWidget(checkbox)
main_layout.addLayout(uav_layout)
# 顯示所有無人機資訊,從左到右顯示
uav_layout = QHBoxLayout()
# 逐個顯示 UAV 的資訊
self.uav_labels = {}
for namespace in self.namespaces:
uav_info_layout = QVBoxLayout() # 每台 UAV 的資訊垂直排列
# 狀態顯示
self.uav_labels[namespace] = {
"status": QLabel("狀態:等待數據..."),
"battery": QLabel("電壓:等待數據..."),
"local_position": QLabel("Local Position等待數據..."),
"gps": QLabel("GPS位置等待數據...\n\n"),
"fix": QLabel("Fix Type等待數據..."),
"satellites": QLabel("衛星數量:等待數據..."),
"groundspeed": QLabel("地面速度:等待數據..."),
"pitch": QLabel("Pitch等待數據..."),
"heading": QLabel("Heading等待數據..."),
"ping": QLabel("Ping等待數據..."),
"loss": QLabel("丟包:等待數據..."),
"frequency": QLabel("頻率:等待數據..."),
"kbps": QLabel("吞吐量:等待數據..."),
"param": QLabel("SR1_EXTRA1等待數據...")
}
# 把每個資訊添加到該 UAV 的垂直佈局中
for label in self.uav_labels[namespace].values():
uav_info_layout.addWidget(label)
# 將該 UAV 的資訊佈局加到主佈局中(從左到右排列)
uav_layout.addLayout(uav_info_layout)
main_layout.addLayout(uav_layout)
# SR1_EXTRA1參數設置
param_layout = QHBoxLayout()
self.paramInput = QLineEdit()
self.paramInput.setPlaceholderText("輸入SR1_EXTRA1的新值")
self.setParamButton = QPushButton("設置SR1_EXTRA1")
self.setParamButton.clicked.connect(self.set_SR1_EXTRA1)
param_layout.addWidget(self.paramInput)
param_layout.addWidget(self.setParamButton)
main_layout.addLayout(param_layout)
# 模式切換區域
mode_layout = QHBoxLayout()
self.modeComboBox = QComboBox()
self.modeComboBox.addItems(["GUIDED", "STABILIZE", "AUTO", "LOITER"])
self.modeButton = QPushButton("切換模式")
self.modeButton.clicked.connect(self.change_mode)
mode_layout.addWidget(QLabel("選擇模式:"))
mode_layout.addWidget(self.modeComboBox)
mode_layout.addWidget(self.modeButton)
main_layout.addLayout(mode_layout)
# 解鎖按鈕
fly_layout = QHBoxLayout()
self.armButton = QPushButton("解鎖")
self.armButton.clicked.connect(self.arm_drone)
# 起飛按鈕
self.takeoffButton = QPushButton("起飛")
self.takeoffButton.clicked.connect(self.takeoff_drone)
# 降落按鈕
self.landButton = QPushButton("降落")
self.landButton.clicked.connect(self.land_drone)
fly_layout.addWidget(self.armButton)
fly_layout.addWidget(self.takeoffButton)
fly_layout.addWidget(self.landButton)
main_layout.addLayout(fly_layout)
# XYZ 設置欄位
xyz_layout = QHBoxLayout()
self.positionX = QLineEdit()
self.positionX.setPlaceholderText("X")
self.positionY = QLineEdit()
self.positionY.setPlaceholderText("Y")
self.positionZ = QLineEdit()
self.positionZ.setPlaceholderText("Z")
self.setPositionButton = QPushButton("設置位置")
self.setPositionButton.clicked.connect(self.set_local_position)
xyz_layout.addWidget(QLabel("輸入位置:"))
xyz_layout.addWidget(self.positionX)
xyz_layout.addWidget(self.positionY)
xyz_layout.addWidget(self.positionZ)
xyz_layout.addWidget(self.setPositionButton)
main_layout.addLayout(xyz_layout)
# 設置 XYZ 的按鈕
#設置重啟按鈕
self.rebootButton = QPushButton("重啟")
self.rebootButton.clicked.connect(self.reboot_drone)
main_layout.addWidget(self.rebootButton)
# 設置主佈局
central_widget = QWidget(self)
central_widget.setLayout(main_layout)
self.setCentralWidget(central_widget)
self.show()
def closeEvent(self, event):
try:
self.workers.stop()
finally:
event.accept()
def update_state(self, namespace, mode):
self.uav_labels[namespace]["status"].setText(f"狀態:{mode}")
def update_battery(self, namespace, voltage):
self.uav_labels[namespace]["battery"].setText(f"電壓:{voltage:.2f} V")
def update_gps(self, namespace, latitude, longitude):
self.uav_labels[namespace]["gps"].setText(f"GPS位置 \n Lat:{latitude:.6f}° \n Lon:{longitude:.6f}°")
def update_gps_status(self, namespace, fix_type, satellites_visible):
fix_type_str = {
0: "No Fix",
1: "No Fix",
2: "2D Fix",
3: "3D Fix",
4: "RTK Float",
5: "RTK Fix"
}.get(fix_type, "Unknown")
self.uav_labels[namespace]["fix"].setText(f"Fix Type{fix_type_str}")
self.uav_labels[namespace]["satellites"].setText(f"衛星數量:{satellites_visible}")
def update_local_position(self, namespace, x, y, z):
self.uav_labels[namespace]["local_position"].setText(f"Local({x:.2f}, {y:.2f}, {z:.2f})")
def update_imu(self, namespace, pitch):
self.uav_labels[namespace]["pitch"].setText(f"Pitch{pitch:.2f}°")
def update_hdg(self, namespace, heading):
self.uav_labels[namespace]["heading"].setText(f"Heading{heading:.2f}°")
def update_groundspeed(self, namespace, groundspeed):
self.uav_labels[namespace]["groundspeed"].setText(f"地面速度:{groundspeed:.2f} m/s")
def update_ping(self, namespace, ping):
self.uav_labels[namespace]["ping"].setText(f"Ping{ping:.2f} ms")
def update_loss(self, namespace, loss):
self.uav_labels[namespace]["loss"].setText(f"丟包:{loss:.2f}%")
def update_frequency(self, namespace, frequency):
self.uav_labels[namespace]["frequency"].setText(f"頻率:{frequency:.2f} Hz")
def update_kbps(self, namespace, kbps):
self.uav_labels[namespace]["kbps"].setText(f"吞吐量:{kbps:.2f} KB/s")
def update_param(self, namespace, value):
self.uav_labels[namespace]["param"].setText(f"SR1_EXTRA1:{value}")
def change_mode(self):
try:
selected_mode = self.modeComboBox.currentText()
for namespace in self.namespaces:
if self.uav_checkboxes[namespace].isChecked():
self.workers.set_mode(namespace, selected_mode)
except Exception as e:
QtWidgets.QMessageBox.warning(self, "錯誤", f"模式切換失敗:{str(e)}")
def arm_drone(self):
try:
for namespace in self.namespaces:
if self.uav_checkboxes[namespace].isChecked():
self.workers.arm(namespace, True)
except Exception as e:
QtWidgets.QMessageBox.warning(self, "錯誤", f"解鎖失敗:{e}")
def takeoff_drone(self):
try:
z_text = self.positionZ.text().strip()
z = float(z_text) if z_text else 5.0
h = 2
for i, namespace in enumerate(self.namespaces):
if self.uav_checkboxes[namespace].isChecked():
self.workers.takeoff(namespace, z + h * i)
except ValueError:
QtWidgets.QMessageBox.warning(self, "錯誤", "請輸入有效的數值!")
def land_drone(self):
for namespace in self.namespaces:
if self.uav_checkboxes[namespace].isChecked():
self.workers.land(namespace)
def set_local_position(self):
try:
x = float(self.positionX.text().strip())
y = float(self.positionY.text().strip())
z = float(self.positionZ.text().strip())
h = 2
for i, namespace in enumerate(self.namespaces):
if self.uav_checkboxes[namespace].isChecked():
self.workers.set_local_position(namespace, x, y, z + h * i)
except ValueError:
QtWidgets.QMessageBox.warning(self, "錯誤", "請輸入有效的數值!")
def reboot_drone(self):
try:
for namespace in self.namespaces:
if self.uav_checkboxes[namespace].isChecked():
self.workers.reboot_drone(namespace)
except Exception as e:
QtWidgets.QMessageBox.warning(self, "錯誤", f"重啟失敗:{e}")
def set_SR1_EXTRA1(self):
param_value = self.paramInput.text().strip()
try:
for namespace in self.namespaces:
if self.uav_checkboxes[namespace].isChecked():
self.workers.set_param(namespace, "SR1_EXTRA1", param_value)
self.workers.request_param(namespace, "SR1_EXTRA1")
except ValueError:
QtWidgets.QMessageBox.warning(self, "錯誤", "請輸入有效的數值!")
def main():
app = QtWidgets.QApplication(sys.argv)
window = DroneControlApp()
window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()

@ -0,0 +1,102 @@
#!/usr/bin/env python3
from pymavlink import mavutil
import socket
import re
import time
import math
def main():
# 建立MAVLink連接
print("建立MAVLink連接...")
connection_string = "udp:127.0.0.1:14550"
master = mavutil.mavlink_connection(connection_string)
master.wait_heartbeat()
print("MAVLink連接已建立")
# 切換到GUIDED模式
master.mav.set_mode_send(
master.target_system,
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
4 # GUIDED模式ID
)
print("已切換到GUIDED模式")
# 解鎖無人機
master.mav.command_long_send(
master.target_system,
master.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0,
1, 0, 0, 0, 0, 0, 0 # 1表示解鎖
)
print("無人機已解鎖")
# 設置UDP服務器
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.bind(('0.0.0.0', 5000))
server_socket.settimeout(0.1) # 非阻塞操作
print("UDP服務器已啟動監聽端口5000")
# 主循環,持續處理數據
while True:
try:
# 接收UDP數據
try:
data, _ = server_socket.recvfrom(1024)
decoded_data = data.decode('utf-8')
# 提取velocity數據
vel_match = re.search(r'"next_velocity":\[([-\d\.\,]+)\]', decoded_data)
if vel_match:
velocity_str = vel_match.group(1)
velocity = [float(x) for x in velocity_str.split(',')]
vx, vy, vz = velocity
# 獲取當前位置
pos_msg = master.recv_match(type='LOCAL_POSITION_NED', blocking=True, timeout=0.5)
if pos_msg:
x, y, z = pos_msg.x, pos_msg.y, pos_msg.z
# 設置type_mask - 僅使用速度控制
type_mask = (
mavutil.mavlink.POSITION_TARGET_TYPEMASK_X_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_Y_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_Z_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_AX_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_AY_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_AZ_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_YAW_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_YAW_RATE_IGNORE
)
# 發送位置目標命令
print(f"發送速度指令: vx={vx}, vy={vy}, vz={vz}")
master.mav.set_position_target_local_ned_send(
0,
master.target_system,
master.target_component,
mavutil.mavlink.MAV_FRAME_LOCAL_NED,
type_mask,
0, 0, 0,
vx, vy, vz, # 速度
0, 0, 0, # 加速度
0, 0 # 航向
)
print("success")
# 要再去偵錯沒有data進來的狀況
except socket.timeout:
pass
time.sleep(0.05) # 控制循環速率
except KeyboardInterrupt:
print("程序終止")
server_socket.close()
break
except Exception as e:
print(f"錯誤: {e}")
time.sleep(1)
if __name__ == "__main__":
main()

@ -0,0 +1,497 @@
#!/usr/bin/env python3
from pymavlink import mavutil
import time
import math
import threading
import sys
class DroneController:
def __init__(self, connection_string="udp:127.0.0.1:14550", drone_id=0):
self.master = mavutil.mavlink_connection(connection_string)
self.drone_id = drone_id
self.connection_string = connection_string
self.master.wait_heartbeat()
print(f"無人機 #{self.drone_id} 已建立連接!")
def set_mode(self, mode):
if mode == 'STABILIZE':
mode_id = 0
elif mode == 'AUTO':
mode_id = 3
elif mode == 'GUIDED':
mode_id = 4
elif mode == 'LOITER':
mode_id = 5
elif mode == 'RTL':
mode_id = 6
elif mode == 'LAND':
mode_id = 9
else:
print(f"不支援的模式: {mode}")
return False
self.master.mav.set_mode_send(
self.master.target_system,
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
mode_id
)
for _ in range(5):
msg = self.master.recv_match(type='HEARTBEAT', blocking=True, timeout=1)
if msg and msg.custom_mode == mode_id:
return True
return False
def arm(self, arm_state=True):
self.master.mav.command_long_send(
self.master.target_system,
self.master.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0,
1 if arm_state else 0, 0, 0, 0, 0, 0, 0
)
for _ in range(5):
msg = self.master.recv_match(type='HEARTBEAT', blocking=True, timeout=1)
if msg:
armed = msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED
if (arm_state and armed) or (not arm_state and not armed):
return True
return False
def takeoff(self, altitude):
self.master.mav.command_long_send(
self.master.target_system,
self.master.target_component,
mavutil.mavlink.MAV_CMD_NAV_TAKEOFF,
0,
0, 0, 0, 0, 0, 0,
altitude
)
timeout = time.time() + 30
while time.time() < timeout:
msg = self.master.recv_match(type='GLOBAL_POSITION_INT', blocking=True, timeout=1)
if msg:
current_alt = msg.relative_alt / 1000.0
if abs(current_alt - altitude) < 0.5:
return True
time.sleep(0.1)
return False
def goto_position_local(self, x, y, z, vx=0, vy=0, vz=0, yaw=0, yaw_rate=0):
z = -z
type_mask = (
mavutil.mavlink.POSITION_TARGET_TYPEMASK_AX_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_AY_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_AZ_IGNORE
)
print(f"無人機 #{self.drone_id} 發送位置指令: x={x}, y={y}, z={-z}, vx={vx}, vy={vy}, vz={vz}")
self.master.mav.set_position_target_local_ned_send(
0,
self.master.target_system,
self.master.target_component,
mavutil.mavlink.MAV_FRAME_LOCAL_NED,
type_mask,
x, y, z,
vx, vy, vz,
0, 0, 0,
yaw, yaw_rate
)
print(f"無人機 #{self.drone_id} 開始監控位置變化...")
start_time = time.time()
last_progress_time = start_time
min_dist = float('inf')
while time.time() - start_time < 30:
msg = self.master.recv_match(type='LOCAL_POSITION_NED', blocking=True, timeout=1)
if msg:
dist = math.sqrt((msg.x - x)**2 + (msg.y - y)**2 + (msg.z - z)**2)
min_dist = min(min_dist, dist)
current_time = time.time()
if current_time - last_progress_time >= 5:
if dist > min_dist + 0.1:
print(f"無人機 #{self.drone_id} 偏離目標,重新調整")
self.master.mav.set_position_target_local_ned_send(
0, self.master.target_system, self.master.target_component,
mavutil.mavlink.MAV_FRAME_LOCAL_NED,
type_mask,
x, y, z, vx, vy, vz, 0, 0, 0, yaw, yaw_rate
)
last_progress_time = current_time
print(f"無人機 #{self.drone_id} 當前位置: x={msg.x:.2f}, y={msg.y:.2f}, z={-msg.z:.2f}, 距離目標: {dist:.2f}m")
if dist < 0.5:
print(f"無人機 #{self.drone_id} 到達目標位置")
return True
vel_msg = self.master.recv_match(type='LOCAL_POSITION_NED', blocking=False)
if vel_msg:
vel_total = math.sqrt(vel_msg.vx**2 + vel_msg.vy**2 + vel_msg.vz**2)
if dist < 1.0 and vel_total < 0.1:
print(f"無人機 #{self.drone_id} 已穩定懸停在目標位置附近")
return True
time.sleep(0.1)
print(f"無人機 #{self.drone_id} 移動超時")
return False
def get_current_state(self):
state = {}
# Mode and armed status
hb_msg = self.master.recv_match(type='HEARTBEAT', blocking=True, timeout=0.5)
if hb_msg:
mode_id = hb_msg.custom_mode
mode_name = {
0: "STABILIZE",
3: "AUTO",
4: "GUIDED",
5: "LOITER",
6: "RTL",
9: "LAND"
}.get(mode_id, f"Unknown({mode_id})")
state['mode'] = mode_name
state['armed'] = bool(hb_msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
# Position
pos_msg = self.master.recv_match(type='LOCAL_POSITION_NED', blocking=True, timeout=1.0)
if pos_msg:
state['position'] = {
'x': round(pos_msg.x, 2),
'y': round(pos_msg.y, 2),
'z': round(-pos_msg.z, 2)
}
# GPS
gps_msg = self.master.recv_match(type='GLOBAL_POSITION_INT', blocking=True, timeout=1.0)
if gps_msg:
state['gps_position'] = {
'lat': gps_msg.lat / 1e7,
'lon': gps_msg.lon / 1e7,
'alt': gps_msg.alt / 1000,
'relative_alt': gps_msg.relative_alt / 1000
}
return state
class MultiDroneController:
def __init__(self, num_drones=5):
self.drones = []
self.num_drones = num_drones
for i in range(num_drones):
port = 14550 + i
connection_string = f"udp:127.0.0.1:{port}"
drone = DroneController(connection_string, i)
self.drones.append(drone)
print(f"已初始化無人機 #{i} 在端口 {port}")
def get_drone(self, drone_id):
if 0 <= drone_id < len(self.drones):
return self.drones[drone_id]
return None
def all_drones_command(self, command, *args):
results = []
threads = []
for drone in self.drones:
thread = threading.Thread(
target=lambda d, cmd, args, results: results.append((d.drone_id, getattr(d, cmd)(*args))),
args=(drone, command, args, results)
)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
for drone_id, success in results:
print(f"無人機 #{drone_id} 執行 {command} 結果: {'成功' if success else '失敗'}")
return all(success for _, success in results)
def print_menu():
menu = [
"\n=== 多機無人機控制界面 ===",
"1. 選擇操作的無人機",
"2. 對所有無人機執行命令",
"3. 查看所有無人機狀態",
"4. 退出",
"0. 顯示此清單",
"=========================="
]
for item in menu:
print(item)
def print_drone_menu():
menu = [
"\n=== 單機無人機控制界面 ===",
"1. 切換至 GUIDED 模式",
"2. 切換至 LAND 模式",
"3. 解鎖",
"4. 上鎖",
"5. 起飛",
"6. 飛到指定位置",
"7. 切換至 RTL 模式(返航)",
"8. 查看當前狀態",
"9. 返回主菜單",
"0. 顯示此清單",
"=========================="
]
for item in menu:
print(item)
def print_all_drones_menu():
menu = [
"\n=== 所有無人機控制界面 ===",
"1. 全部切換至 GUIDED 模式",
"2. 全部切換至 LAND 模式",
"3. 全部解鎖",
"4. 全部上鎖",
"5. 全部起飛",
"6. 全部返航",
"7. 返回主菜單",
"0. 顯示此清單",
"=========================="
]
for item in menu:
print(item)
def control_single_drone(drone):
print_drone_menu()
current_drone_id = drone.drone_id
while True:
try:
choice = input(f"\n無人機 #{current_drone_id} - 請輸入指令編號: ")
if choice == '0':
print_drone_menu()
elif choice == '1':
if drone.set_mode("GUIDED"):
print(f"無人機 #{current_drone_id} 已切換至 GUIDED 模式")
else:
print(f"無人機 #{current_drone_id} 切換模式失敗")
elif choice == '2':
if drone.set_mode("LAND"):
print(f"無人機 #{current_drone_id} 已切換至 LAND 模式")
else:
print(f"無人機 #{current_drone_id} 切換模式失敗")
elif choice == '3':
if drone.arm(True):
print(f"無人機 #{current_drone_id} 已解鎖")
else:
print(f"無人機 #{current_drone_id} 解鎖失敗")
elif choice == '4':
if drone.arm(False):
print(f"無人機 #{current_drone_id} 已上鎖")
else:
print(f"無人機 #{current_drone_id} 上鎖失敗")
elif choice == '5':
altitude = float(input("請輸入起飛高度(公尺): "))
if drone.takeoff(altitude):
print(f"無人機 #{current_drone_id} 已到達目標高度: {altitude}公尺")
else:
print(f"無人機 #{current_drone_id} 起飛失敗")
elif choice == '6':
x = float(input("請輸入X座標(公尺): "))
y = float(input("請輸入Y座標(公尺): "))
z = float(input("請輸入Z座標(公尺,高度為正值): "))
vx = float(input("請輸入X方向速度(公尺/秒,可選): ") or "0")
vy = float(input("請輸入Y方向速度(公尺/秒,可選): ") or "0")
if drone.goto_position_local(x, y, z, vx, vy):
print(f"無人機 #{current_drone_id} 已到達目標位置")
else:
print(f"無人機 #{current_drone_id} 移動失敗")
elif choice == '7':
if drone.set_mode("RTL"):
print(f"無人機 #{current_drone_id} 已切換至 RTL 模式,正在返航")
else:
print(f"無人機 #{current_drone_id} 切換至 RTL 模式失敗")
elif choice == '8':
state = drone.get_current_state()
if state:
print(f"\n=== 無人機 #{current_drone_id} 狀態 ===")
print(f"模式: {state.get('mode', '未知')}")
print(f"解鎖狀態: {'已解鎖' if state.get('armed', False) else '已上鎖'}")
if 'gps_position' in state:
gps = state['gps_position']
print(f"GPS位置: 緯度={gps['lat']:.6f}, 經度={gps['lon']:.6f}, 高度={gps['alt']:.2f}m")
if 'position' in state:
pos = state['position']
print(f"本地位置: X={pos['x']:.2f}m, Y={pos['y']:.2f}m, Z={pos['z']:.2f}m")
print("=============================")
else:
print(f"無法獲取無人機 #{current_drone_id} 狀態")
elif choice == '9':
print("返回主菜單")
break
else:
print("無效的指令,請重新輸入")
except ValueError as e:
print(f"輸入錯誤: {e}")
except Exception as e:
print(f"發生錯誤: {e}")
def control_all_drones(controller):
print_all_drones_menu()
while True:
try:
choice = input("\n請輸入指令編號: ")
if choice == '0':
print_all_drones_menu()
elif choice == '1':
if controller.all_drones_command("set_mode", "GUIDED"):
print("所有無人機已切換至 GUIDED 模式")
else:
print("部分無人機切換模式失敗")
elif choice == '2':
if controller.all_drones_command("set_mode", "LAND"):
print("所有無人機已切換至 LAND 模式")
else:
print("部分無人機切換模式失敗")
elif choice == '3':
if controller.all_drones_command("arm", True):
print("所有無人機已解鎖")
else:
print("部分無人機解鎖失敗")
elif choice == '4':
if controller.all_drones_command("arm", False):
print("所有無人機已上鎖")
else:
print("部分無人機上鎖失敗")
elif choice == '5':
altitude = float(input("請輸入所有無人機起飛高度(公尺): "))
if controller.all_drones_command("takeoff", altitude):
print(f"所有無人機已到達目標高度: {altitude}公尺")
else:
print("部分無人機起飛失敗")
elif choice == '6':
if controller.all_drones_command("set_mode", "RTL"):
print("所有無人機已切換至 RTL 模式,正在返航")
else:
print("部分無人機切換至 RTL 模式失敗")
elif choice == '7':
print("返回主菜單")
break
else:
print("無效的指令,請重新輸入")
except ValueError as e:
print(f"輸入錯誤: {e}")
except Exception as e:
print(f"發生錯誤: {e}")
def display_all_drone_states(controller):
"""顯示所有無人機狀態"""
print("\n=== 所有無人機狀態 ===")
for drone in controller.drones:
max_attempts = 5
state = None
for attempt in range(max_attempts):
state = drone.get_current_state()
if state and 'position' in state:
break
time.sleep(0.2)
print(f"\n--- 無人機 #{drone.drone_id} ---")
print(f"連接: {drone.connection_string}")
if not state:
print("狀態: 無法獲取完整數據")
continue
print(f"飛行模式: {state.get('mode', '未知')}")
print(f"解鎖狀態: {'已解鎖' if state.get('armed', False) else '已上鎖'}")
if 'gps_position' in state:
gps = state['gps_position']
print(f"GPS位置: 緯度={gps['lat']:.6f}, 經度={gps['lon']:.6f}, 高度={gps['alt']:.2f}m")
print(f"相對高度: {gps['relative_alt']:.2f}m")
if 'position' in state:
pos = state['position']
print(f"本地位置: X={pos['x']:.2f}m, Y={pos['y']:.2f}m, Z={pos['z']:.2f}m")
print("-----------------------")
print("=========================")
def main():
controller = MultiDroneController(5)
print_menu()
while True:
try:
choice = input("\n請輸入指令編號: ")
if choice == '0':
print_menu()
elif choice == '1':
drone_id = int(input("請輸入要操作的無人機ID (0-4): "))
drone = controller.get_drone(drone_id)
if drone:
control_single_drone(drone)
else:
print(f"無效的無人機ID: {drone_id}")
elif choice == '2':
control_all_drones(controller)
elif choice == '3':
display_all_drone_states(controller)
elif choice == '4':
print("程式結束")
break
else:
print("無效的指令,請重新輸入")
except ValueError as e:
print(f"輸入錯誤: {e}")
except Exception as e:
print(f"發生錯誤: {e}")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n程式被使用者中斷")
sys.exit(0)

@ -0,0 +1,331 @@
import rclpy
from pymavlink import mavutil
from time import sleep
import time
import socket
import struct
# used at fdm_switch_example_two
import threading
import queue
import json
def mavlink_analyzer_simple(count = 500):
# rclpy.init()
# node = rclpy.create_node('mavlink_analyzer_simple')
print('Inintializing connection')
connection_string="udp:127.0.0.1:14550"
connection = mavutil.mavlink_connection(connection_string)
print('Catch messages')
msg_count = {}
start_time = time.time()
for _ in range(count):
msg = connection.recv_msg()
# msg = connection.recv_match(blocking=True)
if msg:
msg_type = msg.get_type()
if msg_type in msg_count:
msg_count[msg_type] += 1
else:
msg_count[msg_type] = 1
if msg.get_type() == 'SERVO_OUTPUT_RAW':
print(msg)
pass
else:
print('No message yet, 1 second for data to fill')
sleep(1)
print('Messages Type received:')
print(msg_count)
end_time = time.time()
print('Time elapsed: ', end_time - start_time)
print('Closing connection')
connection.close()
def fdm_parser_example(data=None):
if data is None:
data = bytes.fromhex('1a48e903b1340500e803e803e803e803000000000000000000000000000000000000000000000000')
# 1a48e903b1340500e803e803e803e803000000000000000000000000000000000000000000000000
parse_format = 'HHI16H'
if len(data) != struct.calcsize(parse_format):
print("got packet of len %u, expected %u" % (len(data), struct.calcsize(parse_format)))
exit(1)
decoded = struct.unpack(parse_format,data)
magic = decoded[0]
frame_rate_hz = decoded[1]
frame_count = decoded[2]
pwm = decoded[3:]
print("magic: %u, frame_rate_hz: %u, frame_count: %u, pwm: %s" % (magic, frame_rate_hz, frame_count, pwm))
def fdm_switch_example_one():
'''
這個範例在 SITL Gazebo 之間 做一個簡單的UDP轉發器
沒有轉換數據格式
'''
# make a link get fdm from SITL, as a server
sock_s2g = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock_s2g.bind(('', 9002))
sock_s2g.settimeout(0.1)
# set a target link pass JSON to Gazebo, as a client
sock_g2s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ('127.0.0.1', 19002)
packet_count = 0
start_time = time.time()
while True:
try:
# 接到SITL的數據 並轉發給Gazebo
data_s2g, addr_s2g = sock_s2g.recvfrom(1024)
sock_g2s.sendto(data_s2g, server_address)
# 得到Gazebo的回應 並轉發給SITL
data_g2s, addr_g2s = sock_g2s.recvfrom(1024)
sock_s2g.sendto(data_g2s, addr_s2g)
# 解析並保存接收到的數據到 JSON 檔案 (SITL -> Gazebo)
try:
# 解析從 SITL 到 Gazebo 的數據
decoded = struct.unpack('HHI16H', data_s2g)
s2g_json = {
"magic": decoded[0],
"frame_rate_hz": decoded[1],
"frame_count": decoded[2],
"pwm": list(decoded[3:])
}
# 解析從 Gazebo 到 SITL 的數據
# 注意:假設從 Gazebo 返回的數據是字串形式的 JSON
try:
g2s_data_str = data_g2s.decode('utf-8')
# 保存原始數據,適合後續分析
with open('gazebo_response_raw.txt', 'a') as f:
f.write(g2s_data_str + '\n')
except:
# 如果不是 UTF-8 格式,可能是二進制數據
g2s_data_str = str(data_g2s)
# 合併數據到一個 JSON 對象
combined_data = {
"sitl_to_gazebo": s2g_json,
"gazebo_to_sitl": g2s_data_str,
"timestamp": time.time()
}
# 寫入 JSON 檔案
with open('fdm_one_data.json', 'w') as f:
json.dump(combined_data, f, indent=2)
except Exception as e:
print(f"JSON 處理錯誤: {e}")
packet_count += 1
except socket.timeout:
print(f'no value')
#print(f'address:{addr_s2g}')
#pass
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= 1.0:
#print(f"Packets received in the last second: {packet_count}")
print(f'JSON Pack:{data_g2s}')
packet_count = 0
start_time = current_time
fdm_parser_example(data_s2g)
def fdm_switch_example_two():
'''
這個範例在 SITL Gazebo 之間 做一個簡單的UDP轉發器
有轉換數據格式
time_usec
servo1_raw
servo16_raw
'''
# read info from SITL via MAVLink
connection_string="udp:127.0.0.1:14550" # uart mavlink # MATLAB
connection = mavutil.mavlink_connection(connection_string)
# set a target link pass JSON to Gazebo, as a client
sock_g2s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ('127.0.0.1', 19002)
data_queue = queue.Queue()
servo_out = [0] * 16
data_queue.put(servo_out)
def send_data_from_queue(sock, server_address, q, frame_rate_fdm=600, frame_count_fdm=1):
interval = 1.0 / frame_rate_fdm
while True:
start_time = time.time()
try:
data = q.get(timeout=0.1)
if data is None:
break
last_data = data
except queue.Empty:
data = last_data
data = struct.pack('HHI16H', 0x481a, frame_rate_fdm, frame_count_fdm, *servo_out)
sock.sendto(data, server_address)
# 新增:寫入 JSON 檔案
data_json = {
"magic": 0x481a,
"frame_rate_hz": frame_rate_fdm,
"frame_count": frame_count_fdm,
"pwm": list(servo_out)
}
with open('fdm_data.json', 'w') as f:
json.dump(data_json, f)
frame_count_fdm += 1
fdm_parser_example(data) # debug
elapsed_time = time.time() - start_time
sleep_time = interval - elapsed_time
if sleep_time > 0:
sleep(sleep_time)
Running = True
thread = threading.Thread(target=send_data_from_queue, args=(sock_g2s, server_address, data_queue))
thread.start()
while Running:
msgb1 = None
msg = connection.recv_msg()
while msg :
if msg.get_type() == 'SERVO_OUTPUT_RAW':
msgb1 = msg
# break # 這裡不需要break因為我要最後一個 servo_output_raw 的值
msg = connection.recv_msg()
if msgb1:
for i in range(16):
servo_out[i] = getattr(msgb1, f'servo{i+1}_raw')
data_queue.put(servo_out)
msgb1 = None
# Running = False # debug
else:
# print('No message yet, 10 ms for data to fill')
sleep(0.01)
# Example of putting data into the queue
# data_queue.put(data)
# To stop the thread, you can put None into the queue
# data_queue.put(None)
def fdm_switch_example_one_with_remote_forwarding():
'''
這個範例在 SITL Gazebo 之間 做一個簡單的UDP轉發器
並且將JSON數據轉發到指定的遠端IP
'''
# make a link get fdm from SITL, as a server
sock_s2g = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock_s2g.bind(('', 9002))
sock_s2g.settimeout(0.1)
# set a target link pass JSON to Gazebo, as a client
sock_g2s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ('127.0.0.1', 19002)
# 建立與遠端IP的socket連接
remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
remote_address = ('140.120.31.130', 9003) # 遠端IP與埠號您可以根據需要更改埠號
print(f'Setting up forwarding to remote address: {remote_address}')
packet_count = 0
start_time = time.time()
while True:
try:
# 接到SITL的數據 並轉發給Gazebo
data_s2g, addr_s2g = sock_s2g.recvfrom(1024)
sock_g2s.sendto(data_s2g, server_address)
# 得到Gazebo的回應 並轉發給SITL
data_g2s, addr_g2s = sock_g2s.recvfrom(1024)
sock_s2g.sendto(data_g2s, addr_s2g)
# 解析並保存接收到的數據到 JSON 檔案 (SITL -> Gazebo)
try:
# 解析從 SITL 到 Gazebo 的數據
decoded = struct.unpack('HHI16H', data_s2g)
s2g_json = {
"magic": decoded[0],
"frame_rate_hz": decoded[1],
"frame_count": decoded[2],
"pwm": list(decoded[3:])
}
# 解析從 Gazebo 到 SITL 的數據
try:
g2s_data_str = data_g2s.decode('utf-8')
# 保存原始數據,適合後續分析
with open('gazebo_response_raw.txt', 'a') as f:
f.write(g2s_data_str + '\n')
except:
# 如果不是 UTF-8 格式,可能是二進制數據
g2s_data_str = str(data_g2s)
# 合併數據到一個 JSON 對象
combined_data = {
"sitl_to_gazebo": s2g_json,
"gazebo_to_sitl": g2s_data_str,
"timestamp": time.time()
}
# 寫入 JSON 檔案
with open('fdm_one_data.json', 'w') as f:
json.dump(combined_data, f, indent=2)
# 將JSON數據轉發到遠端IP
remote_socket.sendto(json.dumps(combined_data).encode(), remote_address)
except Exception as e:
print(f"JSON 處理錯誤: {e}")
packet_count += 1
except socket.timeout:
print(f'no value')
#pass
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= 1.0:
print(f'JSON Pack:{data_g2s}')
print(f'Forwarded data to remote IP: 140.120.31.130')
packet_count = 0
start_time = current_time
fdm_parser_example(data_s2g)
print('Start')
#fdm_switch_example_two()
#mavlink_analyzer_simple()
#fdm_switch_example_one()
fdm_switch_example_one_with_remote_forwarding()
#fdm_parser_example()

@ -0,0 +1,55 @@
import socket
import re
def start_udp_server(host='0.0.0.0', port=5000, buffer_size=1024):
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.bind((host, port))
print(f"UDP Server running on {host}:{port}")
print("Waiting for data...")
position = None
velocity = None
try:
while True:
data, client_address = server_socket.recvfrom(buffer_size)
print(f"\n----- 接收到的資料 -----")
print(f"{client_address} 接收:")
try:
decoded_data = data.decode('utf-8')
print(f"文本數據: {decoded_data}")
# 直接使用正則表達式提取數據
pos_match = re.search(r'"position":\[([-\d\.\,]+)\]', decoded_data)
vel_match = re.search(r'"velocity":\[([-\d\.\,]+)\]', decoded_data)
if pos_match:
position_str = pos_match.group(1)
position = [float(x) for x in position_str.split(',')]
print(f"Position: {position}")
if vel_match:
velocity_str = vel_match.group(1)
velocity = [float(x) for x in velocity_str.split(',')]
print(f"Velocity: {velocity}")
# 這裡可以對 position 和 velocity 進行進一步操作
except UnicodeDecodeError:
print(f"二進制數據: {data.hex()}")
except Exception as e:
print(f"處理數據時出錯: {e}")
print(f"數據大小: {len(data)} 字節")
print("-----------------------")
except KeyboardInterrupt:
print("Server shutting down...")
finally:
server_socket.close()
if __name__ == "__main__":
start_udp_server(port=5000)

@ -0,0 +1,297 @@
#!/usr/bin/env python3
from pymavlink import mavutil
import socket
import re
import time
import math
import threading
import json
import os
import datetime
import queue
import argparse
from select import select
class Drone:
def __init__(self, instance_id, mavlink_port, udp_port):
self.instance_id = instance_id
self.mavlink_port = mavlink_port
self.udp_port = udp_port
self.running = True
self.connection = None
# 創建數據緩存
self.latest_position = None
# 創建日誌佇列
self.log_queue = queue.Queue()
# 創建日誌文件目錄
self.log_dir = "drone_logs"
if not os.path.exists(self.log_dir):
os.makedirs(self.log_dir)
# 創建一個含時間戳的日誌文件名
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
self.log_file = os.path.join(self.log_dir, f"drone_{self.instance_id}_{timestamp}.log")
# 通訊統計
self.packet_counter = 0
self.start_time = time.time()
self.packets_per_second = 0
def connect(self):
"""僅建立MAVLink連接"""
print(f"Drone {self.instance_id}: 建立MAVLink連接 (Port {self.mavlink_port})...")
connection_string = f"udp:127.0.0.1:{self.mavlink_port}"
self.connection = mavutil.mavlink_connection(connection_string)
self.connection.wait_heartbeat()
print(f"Drone {self.instance_id}: MAVLink連接已建立")
def set_guided_mode(self):
"""切換到GUIDED模式"""
self.connection.mav.set_mode_send(
self.connection.target_system,
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
4 # GUIDED模式ID
)
print(f"Drone {self.instance_id}: 已切換到GUIDED模式")
def arm(self):
"""解鎖無人機"""
self.connection.mav.command_long_send(
self.connection.target_system,
self.connection.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 1, 0, 0, 0, 0, 0, 0 # 1表示解鎖
)
print(f"Drone {self.instance_id}: 無人機已解鎖")
def takeoff(self, altitude=3.0):
"""起飛到指定高度"""
self.connection.mav.command_long_send(
self.connection.target_system,
self.connection.target_component,
mavutil.mavlink.MAV_CMD_NAV_TAKEOFF,
0, 0, 0, 0, 0, 0, 0, altitude
)
print(f"Drone {self.instance_id}: 起飛命令已發送,目標高度{altitude}公尺")
def mavlink_listener(self):
"""事件驅動方式監聽MAVLink訊息"""
# 獲取底層socket
mavlink_socket = self.connection.port.fileno()
while self.running:
# 使用select等待數據無須sleep
readable, _, _ = select([mavlink_socket], [], [], 0.001)
if readable:
msg = self.connection.recv_msg()
if msg and msg.get_type() == 'LOCAL_POSITION_NED':
self.latest_position = msg
def log_worker(self):
"""事件驅動方式處理日誌寫入"""
while self.running:
if not self.log_queue.empty():
try:
# 批量處理日誌
batch = []
while not self.log_queue.empty() and len(batch) < 100:
try:
entry = self.log_queue.get_nowait()
batch.append(entry)
except queue.Empty:
break
# 寫入日誌
if batch:
with open(self.log_file, 'a', encoding='utf-8') as f:
for entry in batch:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception as e:
print(f"Drone {self.instance_id}: 日誌寫入錯誤: {e}")
# 每秒計算一次統計數據
now = time.time()
if now - self.start_time >= 1.0:
self.packets_per_second = self.packet_counter
print(f"Drone {self.instance_id} - 每秒封包數: {self.packets_per_second}")
self.packet_counter = 0
self.start_time = now
# 使用select等待短暫時間而非sleep
select([], [], [], 0.01) # 10ms的微小等待
def log_packet(self, packet_data, packet_num):
"""將數據加入日誌佇列"""
try:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
log_entry = {
"timestamp": timestamp,
"packet_number": packet_num,
"data": packet_data
}
self.log_queue.put(log_entry)
except Exception as e:
print(f"Drone {self.instance_id}: 佇列添加錯誤: {e}")
def run(self):
"""使用事件驅動方式處理UDP通訊"""
# 啟動背景線程
mavlink_thread = threading.Thread(target=self.mavlink_listener)
mavlink_thread.daemon = True
mavlink_thread.start()
log_thread = threading.Thread(target=self.log_worker)
log_thread.daemon = True
log_thread.start()
# 設置UDP服務器
server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 262144) # 增大接收緩衝區
server_socket.bind(('0.0.0.0', self.udp_port))
server_socket.setblocking(0) # 非阻塞模式
print(f"Drone {self.instance_id}: UDP服務器已啟動監聽端口{self.udp_port}")
print(f"Drone {self.instance_id}: 日誌將保存至 {self.log_file}")
# 主事件循環
while self.running:
# 使用select監聽UDP
readable, _, _ = select([server_socket], [], [], 0.001)
if readable:
try:
data, _ = server_socket.recvfrom(4096)
decoded_data = data.decode('utf-8')
# 增加計數器
self.packet_counter += 1
total_packets = self.packet_counter
try:
# 解析JSON
json_data = json.loads(decoded_data)
# 寫入日誌
self.log_packet(json_data, total_packets)
# 提取速度數據
vel_match = re.search(r'"next_velocity":\[([-\d\.\,]+)\]', decoded_data)
if vel_match:
velocity_str = vel_match.group(1)
velocity = [float(x) for x in velocity_str.split(',')]
vx, vy, vz = velocity
# 設置type_mask - 僅使用速度控制
type_mask = (
mavutil.mavlink.POSITION_TARGET_TYPEMASK_X_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_Y_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_Z_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_AX_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_AY_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_AZ_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_YAW_IGNORE |
mavutil.mavlink.POSITION_TARGET_TYPEMASK_YAW_RATE_IGNORE
)
# 直接發送速度指令,不需等待位置數據
self.connection.mav.set_position_target_local_ned_send(
0,
self.connection.target_system,
self.connection.target_component,
mavutil.mavlink.MAV_FRAME_LOCAL_NED,
type_mask,
0, 0, 0, # 位置
vx, vy, -vz, # 速度
0, 0, 0, # 加速度
0, 0 # 航向
)
print(f"Drone {self.instance_id}: 發送速度指令: vx={vx}, vy={vy}, vz={-vz}")
except json.JSONDecodeError as je:
print(f"Drone {self.instance_id}: JSON解析錯誤: {je}")
self.log_packet({"raw_data": decoded_data}, total_packets)
except Exception as e:
print(f"Drone {self.instance_id}: 處理錯誤: {e}")
# 清理資源
server_socket.close()
print(f"Drone {self.instance_id}: 線程結束")
def main():
# 解析命令行參數
parser = argparse.ArgumentParser(description='多無人機控制程序')
parser.add_argument('--drones', type=str, default='1,2,3',
help='要控制的無人機ID用逗號分隔 (例如: 1,2,3)')
args = parser.parse_args()
# 所有可用的無人機配置
all_drone_configs = [
(1, 14550, 5003),
(2, 14560, 5013),
(3, 14570, 5023)
]
# 解析要使用的無人機ID
drone_ids = [int(id_str) for id_str in args.drones.split(',')]
drone_configs = [config for config in all_drone_configs if config[0] in drone_ids]
drones = []
threads = []
try:
# 創建所有無人機實例
for instance_id, mavlink_port, udp_port in drone_configs:
drone = Drone(instance_id, mavlink_port, udp_port)
drones.append(drone)
# 步驟1: 所有無人機連接
for drone in drones:
drone.connect()
# 適當延遲確保連接穩定
time.sleep(1)
# 步驟2: 所有無人機同時切換到GUIDED模式
for drone in drones:
drone.set_guided_mode()
time.sleep(1)
# 步驟3: 所有無人機同時解鎖
for drone in drones:
drone.arm()
time.sleep(1)
# 步驟4: 所有無人機同時起飛
for drone in drones:
drone.takeoff()
# 啟動監聽線程
for drone in drones:
thread = threading.Thread(target=drone.run)
thread.daemon = True
threads.append(thread)
thread.start()
print(f"已啟動 {len(drones)} 台無人機 (ID: {args.drones})按Ctrl+C結束程序")
# 主線程事件循環
while True:
select([], [], [], 0.1) # 使用select代替time.sleep
except KeyboardInterrupt:
print("正在停止所有無人機...")
for drone in drones:
drone.running = False
for thread in threads:
thread.join(timeout=2)
print("程序已結束")
if __name__ == "__main__":
main()

@ -0,0 +1,340 @@
#!/usr/bin/env python3
from pymavlink import mavutil
import time
import sys
import json
import socket
import threading
import os
from datetime import datetime
# 固定初始位置設定 (5,5,0)
INITIAL_POSITION = [5, 5, 0]
# MAVLink 連接設定
connection_string = 'udp:127.0.0.1:14553'
# 網路傳輸設定
target_ip = '140.120.31.130'
target_port = 9053
# 建立 UDP socket 用於傳送數據
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# 封包編號計數器
packet_counter = 0
# 共享變數 - 存儲最新位置
current_position = None
initial_position = INITIAL_POSITION.copy()
position_lock = threading.Lock()
# 連接到無人機
print(f"嘗試連接到 MAVLink: {connection_string}")
try:
connection = mavutil.mavlink_connection(connection_string, autoreconnect=True)
connection.wait_heartbeat()
print(f"成功連接到系統 {connection.target_system}!")
except Exception as e:
print(f"MAVLink 連接失敗: {e}")
sys.exit(1)
print(f"使用指定初始位置: 北={initial_position[0]}, 東={initial_position[1]}, 下={initial_position[2]}")
# 請求位置數據流
connection.mav.request_data_stream_send(
connection.target_system,
connection.target_component,
mavutil.mavlink.MAV_DATA_STREAM_POSITION,
10,
1
)
# 日誌記錄函數
def start_logging():
"""創建並開啟日誌文件"""
log_dir = "logs"
if not os.path.exists(log_dir):
os.makedirs(log_dir)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = os.path.join(log_dir, f"drone_log_{timestamp}.csv")
file = open(log_file, "w")
file.write("packet_id,timestamp,x,y,z,vx,vy,vz\n") # CSV標頭
print(f"日誌記錄已開始: {log_file}")
return file
# 控制函數
def set_guided_mode():
"""設置為 GUIDED 模式"""
connection.mav.command_long_send(
connection.target_system,
connection.target_component,
mavutil.mavlink.MAV_CMD_DO_SET_MODE,
0,
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
4,
0, 0, 0, 0, 0
)
ack = connection.recv_match(type='COMMAND_ACK', blocking=True, timeout=3)
return ack and ack.result == mavutil.mavlink.MAV_RESULT_ACCEPTED
def arm_throttle():
"""解鎖油門"""
connection.mav.command_long_send(
connection.target_system,
connection.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 1, 0, 0, 0, 0, 0, 0
)
ack = connection.recv_match(type='COMMAND_ACK', blocking=True, timeout=3)
return ack and ack.result == mavutil.mavlink.MAV_RESULT_ACCEPTED
def takeoff(altitude):
"""起飛到指定高度"""
connection.mav.command_long_send(
connection.target_system,
connection.target_component,
mavutil.mavlink.MAV_CMD_NAV_TAKEOFF,
0, 0, 0, 0, 0, 0, 0, altitude
)
ack = connection.recv_match(type='COMMAND_ACK', blocking=True, timeout=3)
return ack and ack.result == mavutil.mavlink.MAV_RESULT_ACCEPTED
def set_speed_limit(speed_type, speed):
"""
設置速度限制
Parameters:
-----------
speed_type : int
速度類型 (0=空速, 1=地速, 2=爬升速度, 3=下降速度)
speed : float
速度值 (/)
"""
connection.mav.command_long_send(
connection.target_system,
connection.target_component,
mavutil.mavlink.MAV_CMD_DO_CHANGE_SPEED,
0, # 確認
speed_type, # 參數1: 速度類型
speed, # 參數2: 速度 (m/s)
-1, # 參數3: 油門 (-1 表示不變)
0, 0, 0, 0 # 參數4-7: 未使用
)
ack = connection.recv_match(type='COMMAND_ACK', blocking=True, timeout=3)
success = ack and ack.result == mavutil.mavlink.MAV_RESULT_ACCEPTED
if success:
print(f"已設置速度限制為 {speed:.2f} m/s")
else:
print("設置速度限制失敗")
return success
def goto_position(north, east, down):
"""前往指定位置 (世界座標系)"""
# 將世界座標轉換為相對座標
rel_north = north - initial_position[0]
rel_east = east - initial_position[1]
rel_down = down - initial_position[2]
connection.mav.set_position_target_local_ned_send(
0,
connection.target_system,
connection.target_component,
mavutil.mavlink.MAV_FRAME_LOCAL_NED,
0b0000111111111000,
rel_north, rel_east, rel_down,
0, 0, 0,
0, 0, 0,
0, 0
)
# print(f"相對位置: 北={rel_north:.2f}, 東={rel_east:.2f}, 下={rel_down:.2f}")
def get_current_position():
"""獲取存儲的當前位置"""
with position_lock:
if current_position is None:
return None
return current_position.copy()
def control_interface():
"""命令行控制界面"""
print("無人機控制介面已啟動,輸入命令:")
print(" arm - 解鎖油門")
print(" takeoff [高度] - 起飛")
print(" goto [北] [東] [高度] - 前往位置 (世界座標系)")
print(" pos - 顯示當前位置 (相對座標和世界座標)")
print(" init - 顯示初始位置")
print(" exit - 退出控制")
while True:
try:
cmd = input("命令> ").strip().split()
if not cmd:
continue
if cmd[0] == "arm":
print("嘗試解鎖油門...")
if set_guided_mode() and arm_throttle():
print("成功解鎖油門!")
else:
print("解鎖失敗")
elif cmd[0] == "takeoff":
alt = float(cmd[1]) if len(cmd) > 1 else 3.0
print(f"嘗試起飛到 {alt} 米高...")
if takeoff(alt):
print("起飛命令已發送")
else:
print("起飛失敗")
elif cmd[0] == "goto":
if len(cmd) >= 4:
north = float(cmd[1])
east = float(cmd[2])
down = -float(cmd[3]) # 高度是負的下
print(f"前往世界位置: 北:{north} 東:{east} 下:{-down}")
goto_position(north, east, down)
else:
print("用法: goto [北] [東] [高度]")
elif cmd[0] == "pos":
pos = get_current_position()
if pos:
rel_x, rel_y, rel_z = pos
# 計算世界座標
world_x = initial_position[0] + rel_x
world_y = initial_position[1] + rel_y
world_z = initial_position[2] + rel_z
# NED 轉 ENU
enu_x = rel_y
enu_y = rel_x
enu_z = -rel_z
print("\n當前位置:")
#print(f" 相對座標 (NED): 北={rel_x:.2f}m, 東={rel_y:.2f}m, 下={rel_z:.2f}m")
print(f" 世界座標 : 北={world_x:.2f}m, 東={world_y:.2f}m, 下={-world_z:.2f}m")
#print(f" 相對座標 (ENU): 東={enu_x:.2f}m, 北={enu_y:.2f}m, 上={enu_z:.2f}m")
#print(f" 高度: {-rel_z:.2f}m")
else:
print("尚未獲取到位置信息")
elif cmd[0] == "speed":
if len(cmd) > 1:
speed = float(cmd[1])
set_speed_limit(1, speed) # 1 = 地速
else:
print("用法: speed [速度m/s]")
elif cmd[0] == "climbspeed":
if len(cmd) > 1:
speed = float(cmd[1])
set_speed_limit(2, speed) # 2 = 爬升速度
set_speed_limit(3, speed) # 3 = 下降速度
else:
print("用法: climbspeed [速度m/s]")
elif cmd[0] == "init":
print(f"初始位置 (NED): 北={initial_position[0]:.2f}m, 東={initial_position[1]:.2f}m, 下={initial_position[2]:.2f}m")
elif cmd[0] == "exit":
print("退出控制介面")
break
else:
print(f"未知命令: {cmd[0]}")
except Exception as e:
print(f"命令執行錯誤: {e}")
# 數據傳輸線程函數
def data_sending_thread():
global packet_counter, current_position
# 開啟日誌文件
log_file = start_logging()
try:
print(f"開始接收數據並傳送到 {target_ip}:{target_port}...")
while True:
msg = connection.recv_match(type='LOCAL_POSITION_NED', blocking=True, timeout=1)
if msg is not None:
# 更新當前位置(線程安全)
with position_lock:
current_position = [msg.x, msg.y, msg.z]
# 增加封包編號
packet_counter += 1
# 計算世界座標
world_x = initial_position[0] + msg.x
world_y = initial_position[1] + msg.y
world_z = initial_position[2] + msg.z
# NED 轉換為 ENU 並使用世界座標
enu_position = {
"x": world_x, # 東 (從 NED 的 x)
"y": world_y, # 北 (從 NED 的 y)
"z": -world_z # 上 (從 NED 的 -z)
}
enu_velocity = {
"vx": msg.vy, # 東向速度
"vy": msg.vx, # 北向速度
"vz": -msg.vz # 上向速度
}
# 創建JSON對象 (使用世界座標)
json_data = {
"packet_id": packet_counter,
"timestamp": msg.time_boot_ms,
"enemy_position": enu_position,
"enemy_velocity": enu_velocity
}
# 寫入日誌文件 (CSV格式)
try:
log_file.write(f"{packet_counter},{msg.time_boot_ms},{enu_position['x']},{enu_position['y']},{enu_position['z']},{enu_velocity['vx']},{enu_velocity['vy']},{enu_velocity['vz']}\n")
log_file.flush() # 確保數據即時寫入文件
except Exception as e:
print(f"寫入日誌失敗: {e}")
json_str = json.dumps(json_data)
try:
sock.sendto(json_str.encode('utf-8'), (target_ip, target_port))
except Exception as e:
print(f"傳送數據失敗: {e}")
time.sleep(0.1)
except Exception as e:
print(f"數據傳輸線程錯誤: {e}")
finally:
# 確保關閉日誌文件
log_file.close()
print("日誌文件已關閉")
# 創建並啟動數據發送線程
sender_thread = threading.Thread(target=data_sending_thread)
sender_thread.daemon = True
sender_thread.start()
# 啟動命令行控制界面
try:
control_interface()
except KeyboardInterrupt:
print("\n程序已被用戶中斷")
finally:
if connection:
connection.mav.request_data_stream_send(
connection.target_system,
connection.target_component,
mavutil.mavlink.MAV_DATA_STREAM_ALL,
0, 0
)
print("數據流已停止")
sock.close()
print("網絡連接已關閉")
print(f"總共傳送了 {packet_counter} 個封包")
print("日誌文件已儲存")

@ -0,0 +1,663 @@
import rclpy
from pymavlink import mavutil
from time import sleep
import time
import socket
import struct
# used at fdm_switch_example_two
import threading
import queue
import json
def mavlink_analyzer_simple(count = 500):
# rclpy.init()
# node = rclpy.create_node('mavlink_analyzer_simple')
print('Inintializing connection')
connection_string="udp:127.0.0.1:14550"
connection = mavutil.mavlink_connection(connection_string)
print('Catch messages')
msg_count = {}
start_time = time.time()
for _ in range(count):
msg = connection.recv_msg()
# msg = connection.recv_match(blocking=True)
if msg:
msg_type = msg.get_type()
if msg_type in msg_count:
msg_count[msg_type] += 1
else:
msg_count[msg_type] = 1
if msg.get_type() == 'SERVO_OUTPUT_RAW':
print(msg)
pass
else:
print('No message yet, 1 second for data to fill')
sleep(1)
print('Messages Type received:')
print(msg_count)
end_time = time.time()
print('Time elapsed: ', end_time - start_time)
print('Closing connection')
connection.close()
def fdm_parser_example(data=None):
if data is None:
data = bytes.fromhex('1a48e903b1340500e803e803e803e803000000000000000000000000000000000000000000000000')
# 1a48e903b1340500e803e803e803e803000000000000000000000000000000000000000000000000
parse_format = 'HHI16H'
if len(data) != struct.calcsize(parse_format):
print("got packet of len %u, expected %u" % (len(data), struct.calcsize(parse_format)))
exit(1)
decoded = struct.unpack(parse_format,data)
magic = decoded[0]
frame_rate_hz = decoded[1]
frame_count = decoded[2]
pwm = decoded[3:]
print("magic: %u, frame_rate_hz: %u, frame_count: %u, pwm: %s" % (magic, frame_rate_hz, frame_count, pwm))
def fdm_switch_example_one():
'''
這個範例在 SITL Gazebo 之間 做一個簡單的UDP轉發器
沒有轉換數據格式
'''
# make a link get fdm from SITL, as a server
sock_s2g = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock_s2g.bind(('', 9012))
sock_s2g.settimeout(0.1)
# set a target link pass JSON to Gazebo, as a client
sock_g2s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ('127.0.0.1', 19012)
packet_count = 0
start_time = time.time()
while True:
try:
# 接到SITL的數據 並轉發給Gazebo
data_s2g, addr_s2g = sock_s2g.recvfrom(1024)
sock_g2s.sendto(data_s2g, server_address)
# 得到Gazebo的回應 並轉發給SITL
data_g2s, addr_g2s = sock_g2s.recvfrom(1024)
sock_s2g.sendto(data_g2s, addr_s2g)
# 解析並保存接收到的數據到 JSON 檔案 (SITL -> Gazebo)
try:
# 解析從 SITL 到 Gazebo 的數據
decoded = struct.unpack('HHI16H', data_s2g)
s2g_json = {
"magic": decoded[0],
"frame_rate_hz": decoded[1],
"frame_count": decoded[2],
"pwm": list(decoded[3:])
}
# 解析從 Gazebo 到 SITL 的數據
# 注意:假設從 Gazebo 返回的數據是字串形式的 JSON
try:
g2s_data_str = data_g2s.decode('utf-8')
# 保存原始數據,適合後續分析
with open('gazebo_response_raw.txt', 'a') as f:
f.write(g2s_data_str + '\n')
except:
# 如果不是 UTF-8 格式,可能是二進制數據
g2s_data_str = str(data_g2s)
# 合併數據到一個 JSON 對象
combined_data = {
"sitl_to_gazebo": s2g_json,
"gazebo_to_sitl": g2s_data_str,
"timestamp": time.time()
}
# 寫入 JSON 檔案
with open('fdm_one_data.json', 'w') as f:
json.dump(combined_data, f, indent=2)
except Exception as e:
print(f"JSON 處理錯誤: {e}")
packet_count += 1
except socket.timeout:
print(f'no value')
#print(f'address:{addr_s2g}')
#pass
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= 1.0:
#print(f"Packets received in the last second: {packet_count}")
print(f'JSON Pack:{data_g2s}')
packet_count = 0
start_time = current_time
fdm_parser_example(data_s2g)
def fdm_switch_example_two():
'''
這個範例在 SITL Gazebo 之間 做一個簡單的UDP轉發器
有轉換數據格式
time_usec
servo1_raw
servo16_raw
'''
# read info from SITL via MAVLink
connection_string="udp:127.0.0.1:14550" # uart mavlink # MATLAB
connection = mavutil.mavlink_connection(connection_string)
# set a target link pass JSON to Gazebo, as a client
sock_g2s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ('127.0.0.1', 19012)
data_queue = queue.Queue()
servo_out = [0] * 16
data_queue.put(servo_out)
def send_data_from_queue(sock, server_address, q, frame_rate_fdm=600, frame_count_fdm=1):
interval = 1.0 / frame_rate_fdm
while True:
start_time = time.time()
try:
data = q.get(timeout=0.1)
if data is None:
break
last_data = data
except queue.Empty:
data = last_data
data = struct.pack('HHI16H', 0x481a, frame_rate_fdm, frame_count_fdm, *servo_out)
sock.sendto(data, server_address)
# 新增:寫入 JSON 檔案
data_json = {
"magic": 0x481a,
"frame_rate_hz": frame_rate_fdm,
"frame_count": frame_count_fdm,
"pwm": list(servo_out)
}
with open('fdm_data.json', 'w') as f:
json.dump(data_json, f)
frame_count_fdm += 1
fdm_parser_example(data) # debug
elapsed_time = time.time() - start_time
sleep_time = interval - elapsed_time
if sleep_time > 0:
sleep(sleep_time)
Running = True
thread = threading.Thread(target=send_data_from_queue, args=(sock_g2s, server_address, data_queue))
thread.start()
while Running:
msgb1 = None
msg = connection.recv_msg()
while msg :
if msg.get_type() == 'SERVO_OUTPUT_RAW':
msgb1 = msg
# break # 這裡不需要break因為我要最後一個 servo_output_raw 的值
msg = connection.recv_msg()
if msgb1:
for i in range(16):
servo_out[i] = getattr(msgb1, f'servo{i+1}_raw')
data_queue.put(servo_out)
msgb1 = None
# Running = False # debug
else:
# print('No message yet, 10 ms for data to fill')
sleep(0.01)
# Example of putting data into the queue
# data_queue.put(data)
# To stop the thread, you can put None into the queue
# data_queue.put(None)
def fdm_switch_example_one_with_remote_forwarding():
'''
這個範例在 SITL Gazebo 之間 做一個簡單的UDP轉發器
並且將JSON數據轉發到指定的遠端IP
'''
# make a link get fdm from SITL, as a server
sock_s2g = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock_s2g.bind(('', 9012))
sock_s2g.settimeout(0.1)
# set a target link pass JSON to Gazebo, as a client
sock_g2s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server_address = ('127.0.0.1', 19012)
# 建立與遠端IP的socket連接
remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
remote_address = ('140.120.31.130', 9003) # 遠端IP與埠號您可以根據需要更改埠號
print(f'Setting up forwarding to remote address: {remote_address}')
packet_count = 0
start_time = time.time()
while True:
try:
# 接到SITL的數據 並轉發給Gazebo
data_s2g, addr_s2g = sock_s2g.recvfrom(1024)
sock_g2s.sendto(data_s2g, server_address)
# 得到Gazebo的回應 並轉發給SITL
data_g2s, addr_g2s = sock_g2s.recvfrom(1024)
sock_s2g.sendto(data_g2s, addr_s2g)
# 解析並保存接收到的數據到 JSON 檔案 (SITL -> Gazebo)
try:
# 解析從 SITL 到 Gazebo 的數據
decoded = struct.unpack('HHI16H', data_s2g)
s2g_json = {
"magic": decoded[0],
"frame_rate_hz": decoded[1],
"frame_count": decoded[2],
"pwm": list(decoded[3:])
}
# 解析從 Gazebo 到 SITL 的數據
try:
g2s_data_str = data_g2s.decode('utf-8')
# 保存原始數據,適合後續分析
with open('gazebo_response_raw.txt', 'a') as f:
f.write(g2s_data_str + '\n')
except:
# 如果不是 UTF-8 格式,可能是二進制數據
g2s_data_str = str(data_g2s)
# 合併數據到一個 JSON 對象
combined_data = {
"sitl_to_gazebo": s2g_json,
"gazebo_to_sitl": g2s_data_str,
"timestamp": time.time()
}
# 寫入 JSON 檔案
with open('fdm_one_data.json', 'w') as f:
json.dump(combined_data, f, indent=2)
# 將JSON數據轉發到遠端IP
remote_socket.sendto(json.dumps(combined_data).encode(), remote_address)
except Exception as e:
print(f"JSON 處理錯誤: {e}")
packet_count += 1
except socket.timeout:
print(f'no value')
#pass
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= 1.0:
print(f'JSON Pack:{data_g2s}')
print(f'Forwarded data to remote IP: 140.120.31.130')
packet_count = 0
start_time = current_time
fdm_parser_example(data_s2g)
def fdm_switch_multiple(num_systems=3):
'''
Create multiple UDP forwarders between SITL and Gazebo using threading
Each forwarder uses its own set of ports following the pattern:
- SITL ports: 9002, 9012, 9022, ...
- Gazebo ports: 19002, 19012, 19022, ...
Parameters:
num_systems (int): Number of forwarding pairs to create
'''
# Flag to control thread execution
running = True
def forwarder_thread(system_id):
sitl_port = 9002 + (system_id * 10)
gazebo_port = 19002 + (system_id * 10)
# Create SITL socket (server)
sitl_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sitl_socket.bind(('', sitl_port))
sitl_socket.settimeout(0.1)
# Create Gazebo socket (client)
gazebo_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
gazebo_socket.settimeout(0.1)
gazebo_address = ('127.0.0.1', gazebo_port)
print(f"System {system_id}: Forwarding {sitl_port}{gazebo_port}")
packet_count = 0
start_time = time.time()
last_data_s2g = None
try:
while running:
try:
# Receive from SITL and forward to Gazebo
data_s2g, addr_s2g = sitl_socket.recvfrom(1024)
last_data_s2g = data_s2g
gazebo_socket.sendto(data_s2g, gazebo_address)
try:
# Get response from Gazebo and send back to SITL
data_g2s, addr_g2s = gazebo_socket.recvfrom(1024)
sitl_socket.sendto(data_g2s, addr_s2g)
# Process and store data
try:
# Parse SITL to Gazebo data
decoded = struct.unpack('HHI16H', data_s2g)
s2g_json = {
"magic": decoded[0],
"frame_rate_hz": decoded[1],
"frame_count": decoded[2],
"pwm": list(decoded[3:])
}
# Try to parse Gazebo to SITL data
try:
g2s_data_str = data_g2s.decode('utf-8')
if packet_count % 50 == 0:
with open(f'gazebo_response_raw_{system_id}.txt', 'a') as f:
f.write(g2s_data_str + '\n')
except:
g2s_data_str = str(data_g2s)
# Combine data
combined_data = {
"sitl_to_gazebo": s2g_json,
"gazebo_to_sitl": g2s_data_str,
"timestamp": time.time(),
"system_id": system_id,
"sitl_port": sitl_port,
"gazebo_port": gazebo_port
}
# Save to JSON file (once per second)
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= 1.0:
with open(f'fdm_data_system_{system_id}.json', 'w') as f:
json.dump(combined_data, f, indent=2)
except Exception as e:
print(f"System {system_id} - JSON processing error: {e}")
packet_count += 1
except socket.timeout:
# No response from Gazebo, continue
pass
except socket.timeout:
# No data from SITL, continue
pass
# Print stats periodically
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= 1.0:
# print(f"System {system_id} - Packets: {packet_count} in the last second")
if packet_count > 0 and last_data_s2g:
print(f'System {system_id} - JSON Pack: {data_g2s}')
fdm_parser_example(last_data_s2g)
packet_count = 0
start_time = current_time
except Exception as e:
print(f"System {system_id} - Error in forwarder thread: {e}")
finally:
# Clean up resources
sitl_socket.close()
gazebo_socket.close()
print(f"System {system_id} - Forwarder thread terminated")
# Create and start threads for each system
threads = []
for i in range(num_systems):
thread = threading.Thread(target=forwarder_thread, args=(i,))
thread.daemon = True
threads.append(thread)
thread.start()
# Keep the main thread running
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down all forwarders...")
running = False
time.sleep(1)
print("Done.")
return
def fdm_switch_multiple_with_remote(num_systems=3, remote_ip='140.120.31.130'):
'''
Create multiple UDP forwarders between SITL and Gazebo using threading
Also forwards the combined data to a remote IP address
Each forwarder uses its own set of ports following the pattern:
- SITL ports: 9002, 9012, 9022, ...
- Gazebo ports: 19002, 19012, 19022, ...
- Remote ports: 9003, 9013, 9023, ... (for the remote forwarding)
Parameters:
num_systems (int): Number of forwarding pairs to create
remote_ip (str): IP address for remote forwarding
'''
# Flag to control thread execution
running = True
# Add shared counters for total packets
total_packets_sent = 0
total_packets_lock = threading.Lock() # For thread safety
def forwarder_thread(system_id):
nonlocal total_packets_sent
sitl_port = 9002 + (system_id * 10)
gazebo_port = 19002 + (system_id * 10)
remote_port = 9003 + (system_id * 10)
# Create SITL socket (server)
sitl_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sitl_socket.bind(('', sitl_port))
sitl_socket.settimeout(0.1)
# Create Gazebo socket (client)
gazebo_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
gazebo_socket.settimeout(0.1)
gazebo_address = ('127.0.0.1', gazebo_port)
# Create remote forwarding socket
remote_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
remote_address = (remote_ip, remote_port)
print(f"System {system_id}: SITL:{sitl_port} → Gazebo:{gazebo_port}, Remote:{remote_ip}:{remote_port}")
packet_count = 0
start_time = time.time()
last_data_s2g = None
forward_counter = 0 # 添加轉發計數器
system_packets_sent = 0 # Local counter for this system
try:
while running:
try:
# Receive from SITL and forward to Gazebo
data_s2g, addr_s2g = sitl_socket.recvfrom(1024)
last_data_s2g = data_s2g
gazebo_socket.sendto(data_s2g, gazebo_address)
try:
# Get response from Gazebo and send back to SITL
data_g2s, addr_g2s = gazebo_socket.recvfrom(1024)
sitl_socket.sendto(data_g2s, addr_s2g)
# Process and store data
try:
# Parse SITL to Gazebo data
decoded = struct.unpack('HHI16H', data_s2g)
s2g_json = {
"magic": decoded[0],
"frame_rate_hz": decoded[1],
"frame_count": decoded[2],
"pwm": list(decoded[3:])
}
# Try to parse Gazebo to SITL data
try:
g2s_data_str = data_g2s.decode('utf-8')
if packet_count % 50 == 0:
with open(f'gazebo_response_raw_{system_id}.txt', 'a') as f:
f.write(g2s_data_str + '\n')
except:
g2s_data_str = str(data_g2s)
# Combine data
combined_data = {
"sitl_to_gazebo": s2g_json,
"gazebo_to_sitl": g2s_data_str,
"timestamp": time.time(),
"system_id": system_id,
"sitl_port": sitl_port,
"gazebo_port": gazebo_port
}
'''
# 每n筆才轉發一次
forward_counter += 1
if forward_counter >= 18:
# Forward data to remote server
remote_socket.sendto(json.dumps(combined_data).encode(), remote_address)
#print(f"System {system_id} - Send data to remote (第{packet_count}個包)")
forward_counter = 0 # 重置計數器
'''
# 使用模運算代替計數器重置
forward_counter += 1
if packet_count % 1 == 0:
# Forward data to remote server
remote_socket.sendto(json.dumps(combined_data).encode(), remote_address)
system_packets_sent += 1
with total_packets_lock:
total_packets_sent += 1
# Debug用看每秒傳了多少筆出去
#print(f"System {system_id} - Send data to remote (第{packet_count}個包)")
# Save to JSON file (once per second)
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= 1.0:
with open(f'fdm_data_system_{system_id}.json', 'w') as f:
json.dump(combined_data, f, indent=2)
except Exception as e:
print(f"System {system_id} - JSON processing error: {e}")
packet_count += 1
except socket.timeout:
# No response from Gazebo, continue
pass
except socket.timeout:
# No data from SITL, continue
pass
# Print stats periodically
current_time = time.time()
elapsed_time = current_time - start_time
if elapsed_time >= 1.0:
print(f"System {system_id} - Packets: {packet_count} in the last second")
if packet_count > 0 and last_data_s2g:
fdm_parser_example(last_data_s2g)
packet_count = 0
start_time = current_time
except Exception as e:
print(f"System {system_id} - Error in forwarder thread: {e}")
finally:
# Clean up resources
sitl_socket.close()
gazebo_socket.close()
remote_socket.close()
print(f"System {system_id} - Forwarder thread terminated")
# Create statistics display thread
def stats_display_thread():
start_time = time.time()
while running:
time.sleep(5) # Display stats every 5 seconds
elapsed = time.time() - start_time
with total_packets_lock:
rate = total_packets_sent / elapsed if elapsed > 0 else 0
print(f"\n===== STATISTICS =====")
print(f"Total packets sent: {total_packets_sent}")
print(f"Average rate: {rate:.2f} packets/sec")
print(f"Running time: {elapsed:.1f} seconds")
print(f"======================\n")
# Create and start threads for each system
threads = []
for i in range(num_systems):
thread = threading.Thread(target=forwarder_thread, args=(i,))
thread.daemon = True
threads.append(thread)
thread.start()
# Start stats display thread
stats_thread = threading.Thread(target=stats_display_thread)
stats_thread.daemon = True
stats_thread.start()
# Keep the main thread running
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down all forwarders...")
running = False
time.sleep(1)
print(f"Final total packets sent: {total_packets_sent}")
print("Done.")
return
print('Start')
#fdm_switch_example_two()
#mavlink_analyzer_simple()
#fdm_switch_example_one()
#fdm_switch_example_one_with_remote_forwarding()
#fdm_switch_multiple(1) # Create 3 forwarding pairs
fdm_switch_multiple_with_remote(3) # Create 1 forwarding pair with remote forwarding
#fdm_parser_example()

Binary file not shown.
Loading…
Cancel
Save