Merge GUI 1.0.1 from ken
parent
44d53f51fb
commit
2937610938
@ -0,0 +1,687 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QLineEdit, QComboBox)
|
||||||
|
from PyQt6.QtCore import pyqtSignal
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
|
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
|
||||||
|
serial_connection_added = pyqtSignal(str, int) # port, baudrate
|
||||||
|
serial_connection_toggled = pyqtSignal(dict, QPushButton, QLabel) # conn, btn, status_label
|
||||||
|
serial_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.serial_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("14550")
|
||||||
|
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)
|
||||||
|
|
||||||
|
# ========== Serial 區域 ==========
|
||||||
|
serial_title = QLabel("Serial")
|
||||||
|
serial_title.setStyleSheet("""
|
||||||
|
color: #DDD;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 5px;
|
||||||
|
background-color: #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
""")
|
||||||
|
layout.addWidget(serial_title)
|
||||||
|
|
||||||
|
# Serial 連接列表容器
|
||||||
|
self.serial_list_widget = QWidget()
|
||||||
|
self.serial_list_layout = QVBoxLayout(self.serial_list_widget)
|
||||||
|
self.serial_list_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.serial_list_layout.setSpacing(5)
|
||||||
|
layout.addWidget(self.serial_list_widget)
|
||||||
|
|
||||||
|
# Serial 添加新連接區域
|
||||||
|
add_serial_widget = QWidget()
|
||||||
|
add_serial_layout = QHBoxLayout(add_serial_widget)
|
||||||
|
add_serial_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
|
self.serial_port_combo = QComboBox()
|
||||||
|
self.serial_port_combo.setStyleSheet("""
|
||||||
|
QComboBox {
|
||||||
|
background-color: #333;
|
||||||
|
color: #DDD;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
QComboBox::drop-down {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
QComboBox::down-arrow {
|
||||||
|
image: none;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
border-top: 5px solid #DDD;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self._refresh_serial_ports()
|
||||||
|
|
||||||
|
refresh_ports_btn = QPushButton("↻")
|
||||||
|
refresh_ports_btn.setFixedWidth(35)
|
||||||
|
refresh_ports_btn.clicked.connect(self._refresh_serial_ports)
|
||||||
|
refresh_ports_btn.setToolTip("重新掃描串口")
|
||||||
|
refresh_ports_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #444;
|
||||||
|
color: #DDD;
|
||||||
|
border: none;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background-color: #555; }
|
||||||
|
""")
|
||||||
|
|
||||||
|
self.serial_baudrate_combo = QComboBox()
|
||||||
|
self.serial_baudrate_combo.addItems(['9600', '19200', '38400', '57600', '115200'])
|
||||||
|
self.serial_baudrate_combo.setCurrentText('57600')
|
||||||
|
self.serial_baudrate_combo.setFixedWidth(100)
|
||||||
|
self.serial_baudrate_combo.setStyleSheet("""
|
||||||
|
QComboBox {
|
||||||
|
background-color: #333;
|
||||||
|
color: #DDD;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
QComboBox::drop-down {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
QComboBox::down-arrow {
|
||||||
|
image: none;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
border-top: 5px solid #DDD;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
add_serial_btn = QPushButton("添加")
|
||||||
|
add_serial_btn.clicked.connect(self._handle_add_serial)
|
||||||
|
add_serial_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_serial_layout.addWidget(QLabel("Port:", styleSheet="color: #DDD;"))
|
||||||
|
add_serial_layout.addWidget(self.serial_port_combo)
|
||||||
|
add_serial_layout.addWidget(refresh_ports_btn)
|
||||||
|
add_serial_layout.addWidget(QLabel("Baud:", styleSheet="color: #DDD;"))
|
||||||
|
add_serial_layout.addWidget(self.serial_baudrate_combo)
|
||||||
|
add_serial_layout.addWidget(add_serial_btn)
|
||||||
|
|
||||||
|
layout.addWidget(add_serial_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)
|
||||||
|
|
||||||
|
# 只清空 port 輸入框,保留 IP
|
||||||
|
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 _refresh_serial_ports(self):
|
||||||
|
"""重新掃描可用的串口"""
|
||||||
|
self.serial_port_combo.clear()
|
||||||
|
|
||||||
|
# 掃描 Linux 下的串口設備
|
||||||
|
ports = []
|
||||||
|
|
||||||
|
# 掃描 USB 串口
|
||||||
|
usb_ports = glob.glob('/dev/ttyUSB*')
|
||||||
|
ports.extend(usb_ports)
|
||||||
|
|
||||||
|
# 掃描 ACM 串口
|
||||||
|
acm_ports = glob.glob('/dev/ttyACM*')
|
||||||
|
ports.extend(acm_ports)
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
ports.sort()
|
||||||
|
|
||||||
|
if ports:
|
||||||
|
self.serial_port_combo.addItems(ports)
|
||||||
|
else:
|
||||||
|
self.serial_port_combo.addItem("沒有找到串口")
|
||||||
|
self.serial_port_combo.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.serial_port_combo.setEnabled(True)
|
||||||
|
|
||||||
|
def _handle_add_serial(self):
|
||||||
|
"""處理添加 Serial 連接"""
|
||||||
|
port = self.serial_port_combo.currentText()
|
||||||
|
baudrate_text = self.serial_baudrate_combo.currentText()
|
||||||
|
|
||||||
|
if not port or port == "沒有找到串口":
|
||||||
|
self.status_message.emit("請選擇有效的串口", 3000)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
baudrate = int(baudrate_text)
|
||||||
|
except ValueError:
|
||||||
|
self.status_message.emit("波特率格式錯誤", 3000)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 檢查是否已存在相同連接
|
||||||
|
for conn in self.serial_connections:
|
||||||
|
if conn['port'] == port:
|
||||||
|
self.status_message.emit("連接已存在", 3000)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 發送信號通知主窗口
|
||||||
|
self.serial_connection_added.emit(port, baudrate)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def create_serial_connection_panel(self, conn):
|
||||||
|
"""創建 Serial 連接面板"""
|
||||||
|
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['port']} @ {conn['baudrate']}")
|
||||||
|
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.serial_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.serial_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_serial_panel(self, conn):
|
||||||
|
"""添加 Serial 連接面板到列表"""
|
||||||
|
panel = self.create_serial_connection_panel(conn)
|
||||||
|
self.serial_list_layout.addWidget(panel)
|
||||||
|
self.serial_connections.append(conn)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
def remove_serial_connection_from_list(self, conn):
|
||||||
|
"""從列表中移除 Serial 連接"""
|
||||||
|
if conn in self.serial_connections:
|
||||||
|
self.serial_connections.remove(conn)
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from PyQt6.QtWidgets import QTableWidget, QTableWidgetItem, QHeaderView, QLabel
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
class OverviewTable(QTableWidget):
|
||||||
|
"""總覽表格,顯示所有無人機的狀態資訊"""
|
||||||
|
|
||||||
|
# 默認的資訊類型和映射
|
||||||
|
DEFAULT_INFO_TYPES = ["模式", "ARM", "電壓", "經度", "緯度", "高度", "位置", "速度", "地速", "航向",
|
||||||
|
"空速", "油門", "HUD ALT", "爬升率", "Roll", "Pitch", "Yaw", "丟包", "延遲"]
|
||||||
|
|
||||||
|
DEFAULT_INFO_TYPE_MAP = {
|
||||||
|
"mode": 0,
|
||||||
|
"armed": 1,
|
||||||
|
"battery": 2,
|
||||||
|
"longitude": 3,
|
||||||
|
"latitude": 4,
|
||||||
|
"altitude": 5,
|
||||||
|
"local": 6,
|
||||||
|
"velocity": 7,
|
||||||
|
"groundspeed": 8,
|
||||||
|
"heading": 9,
|
||||||
|
"airspeed": 10,
|
||||||
|
"throttle": 11,
|
||||||
|
"hud_alt": 12,
|
||||||
|
"climb": 13,
|
||||||
|
"roll": 14,
|
||||||
|
"pitch": 15,
|
||||||
|
"yaw": 16,
|
||||||
|
"loss_rate": 17,
|
||||||
|
"ping": 18
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, info_types=None, info_type_map=None, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
# 使用提供的或默認的資訊類型
|
||||||
|
self.info_types = info_types if info_types is not None else self.DEFAULT_INFO_TYPES
|
||||||
|
self.info_type_map = info_type_map if info_type_map is not None else self.DEFAULT_INFO_TYPE_MAP
|
||||||
|
self.drones = {} # 存儲無人機面板的引用
|
||||||
|
|
||||||
|
# 初始化表格
|
||||||
|
self.setColumnCount(1)
|
||||||
|
self.setRowCount(len(self.info_types))
|
||||||
|
self.setHorizontalHeaderLabels(["資訊"])
|
||||||
|
header = self.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
self.verticalHeader().setVisible(False)
|
||||||
|
|
||||||
|
# 設置第一列的資訊類型
|
||||||
|
for i, txt in enumerate(self.info_types):
|
||||||
|
item = QTableWidgetItem(txt)
|
||||||
|
item.setFlags(Qt.ItemFlag.ItemIsEnabled)
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.setItem(i, 0, item)
|
||||||
|
|
||||||
|
def set_drones(self, drones):
|
||||||
|
"""設置無人機面板字典的引用"""
|
||||||
|
self.drones = drones
|
||||||
|
|
||||||
|
def update_table(self, drone_id=None, field=None, value=None):
|
||||||
|
"""更新總覽表格
|
||||||
|
|
||||||
|
Args:
|
||||||
|
drone_id: 無人機 ID
|
||||||
|
field: 欄位名稱 (如 'mode', 'altitude' 等)
|
||||||
|
value: 要更新的值
|
||||||
|
"""
|
||||||
|
# 更新特定儲存格
|
||||||
|
if drone_id and field and value:
|
||||||
|
if drone_id not in self.drones:
|
||||||
|
return
|
||||||
|
|
||||||
|
col = 1 + list(self.drones.keys()).index(drone_id)
|
||||||
|
row = self.info_type_map.get(field, -1)
|
||||||
|
|
||||||
|
if row == -1:
|
||||||
|
return # 無效的欄位
|
||||||
|
|
||||||
|
item = self.item(row, col)
|
||||||
|
if not item:
|
||||||
|
item = QTableWidgetItem()
|
||||||
|
self.setItem(row, col, item)
|
||||||
|
|
||||||
|
item.setText(value)
|
||||||
|
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
# 如果沒有指定更新,刷新整個表格
|
||||||
|
if drone_id is None:
|
||||||
|
self.refresh_all()
|
||||||
|
|
||||||
|
def refresh_all(self):
|
||||||
|
"""刷新整個表格"""
|
||||||
|
cols = 1 + len(self.drones)
|
||||||
|
self.setColumnCount(cols)
|
||||||
|
headers = ["資訊"] + list(self.drones.keys())
|
||||||
|
self.setHorizontalHeaderLabels(headers)
|
||||||
|
|
||||||
|
for col, did in enumerate(self.drones, start=1):
|
||||||
|
panel = self.drones[did]
|
||||||
|
for field, row in self.info_type_map.items():
|
||||||
|
lbl = panel.findChild(QLabel, f"{did}_{field}")
|
||||||
|
val = lbl.text() if lbl else "--"
|
||||||
|
val_item = QTableWidgetItem(val)
|
||||||
|
val_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.setItem(row, col, val_item)
|
||||||
|
|
||||||
|
def add_drone_column(self, drone_id):
|
||||||
|
"""當新增無人機時,添加一列"""
|
||||||
|
if drone_id in self.drones:
|
||||||
|
self.refresh_all()
|
||||||
|
|
||||||
|
def remove_drone_column(self, drone_id):
|
||||||
|
"""當移除無人機時,刷新表格"""
|
||||||
|
if drone_id in self.drones:
|
||||||
|
self.refresh_all()
|
||||||
@ -0,0 +1,299 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
獨立測試腳本 — 驗證 MissionExecutor + MavlinkSender 在 SITL 環境下的運作
|
||||||
|
|
||||||
|
使用方式:
|
||||||
|
1. 啟動 SITL
|
||||||
|
2. 修改下方 CONFIG 區塊
|
||||||
|
3. python3 test_mission.py
|
||||||
|
"""
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pymavlink import mavutil
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
from PyQt6.QtCore import QTimer
|
||||||
|
|
||||||
|
from mission_planner import FormationPlanner, MissionType
|
||||||
|
from command_sender import MavlinkSender
|
||||||
|
from mission_executor import MissionExecutor, MissionState
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================================
|
||||||
|
# CONFIG
|
||||||
|
# ================================================================================
|
||||||
|
|
||||||
|
# 接收用連線 (讀取無人機狀態)
|
||||||
|
RECV_CONNECTION = "udp:127.0.0.1:14550"
|
||||||
|
|
||||||
|
# 發送用連線 (發送 setpoint 指令)
|
||||||
|
SEND_CONNECTION = "udpout:127.0.0.1:14550"
|
||||||
|
|
||||||
|
# 要控制的無人機 sysid 列表
|
||||||
|
DRONE_SYSIDS = [1]
|
||||||
|
|
||||||
|
# 起飛高度 (公尺)
|
||||||
|
TAKEOFF_ALT = 10.0
|
||||||
|
|
||||||
|
# 任務規劃參數
|
||||||
|
FORMATION_SPACING = 5.0
|
||||||
|
BASE_ALTITUDE = 10.0
|
||||||
|
ALTITUDE_DIFF = 2.0
|
||||||
|
ARRIVAL_RADIUS = 2.0
|
||||||
|
|
||||||
|
# 測試模式: "formation" 或 "grid_sweep"
|
||||||
|
TEST_MODE = "formation"
|
||||||
|
|
||||||
|
# Grid Sweep 專用設定
|
||||||
|
GRID_LINE_SPACING = 5.0
|
||||||
|
|
||||||
|
# ================================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class SITLDroneManager:
|
||||||
|
"""管理 SITL 無人機的連線、起飛前置作業"""
|
||||||
|
|
||||||
|
def __init__(self, connection_string, sysids):
|
||||||
|
self.connection_string = connection_string
|
||||||
|
self.sysids = sysids
|
||||||
|
self.mav = None
|
||||||
|
self.drone_gps = {}
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""建立 MAVLink 連線並等待心跳"""
|
||||||
|
print(f"連線到 {self.connection_string} ...")
|
||||||
|
self.mav = mavutil.mavlink_connection(self.connection_string)
|
||||||
|
self.mav.wait_heartbeat()
|
||||||
|
print(f"已收到心跳: sysid={self.mav.target_system}, compid={self.mav.target_component}")
|
||||||
|
|
||||||
|
def set_guided_and_arm(self, sysid):
|
||||||
|
"""切換到 GUIDED 模式並解鎖"""
|
||||||
|
print(f"\n--- sysid={sysid}: 切換 GUIDED + 解鎖 ---")
|
||||||
|
|
||||||
|
# 切換 GUIDED 模式
|
||||||
|
self.mav.mav.command_long_send(
|
||||||
|
sysid, 1,
|
||||||
|
mavutil.mavlink.MAV_CMD_DO_SET_MODE,
|
||||||
|
0,
|
||||||
|
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
|
||||||
|
4, # GUIDED = 4
|
||||||
|
0, 0, 0, 0, 0
|
||||||
|
)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# 解鎖
|
||||||
|
self.mav.mav.command_long_send(
|
||||||
|
sysid, 1,
|
||||||
|
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
|
||||||
|
0, 1, 0, 0, 0, 0, 0, 0
|
||||||
|
)
|
||||||
|
time.sleep(1)
|
||||||
|
print(f" sysid={sysid}: GUIDED + ARMED")
|
||||||
|
|
||||||
|
def takeoff(self, sysid, altitude):
|
||||||
|
"""起飛到指定高度"""
|
||||||
|
print(f" sysid={sysid}: 起飛到 {altitude}m ...")
|
||||||
|
self.mav.mav.command_long_send(
|
||||||
|
sysid, 1,
|
||||||
|
mavutil.mavlink.MAV_CMD_NAV_TAKEOFF,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, altitude
|
||||||
|
)
|
||||||
|
|
||||||
|
def wait_for_altitude(self, sysid, target_alt, timeout=30):
|
||||||
|
"""等待無人機到達指定高度"""
|
||||||
|
print(f" sysid={sysid}: 等待到達 {target_alt}m ...")
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < timeout:
|
||||||
|
msg = self.mav.recv_match(type='GLOBAL_POSITION_INT', blocking=True, timeout=1)
|
||||||
|
if msg and msg.get_srcSystem() == sysid:
|
||||||
|
alt = msg.relative_alt / 1000.0
|
||||||
|
if alt >= target_alt * 0.9:
|
||||||
|
print(f" sysid={sysid}: 已到達 {alt:.1f}m")
|
||||||
|
return True
|
||||||
|
print(f" sysid={sysid}: 等待超時!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def update_gps_once(self):
|
||||||
|
"""讀取一輪 GPS 資料更新 drone_gps"""
|
||||||
|
deadline = time.time() + 3
|
||||||
|
received = set()
|
||||||
|
while time.time() < deadline and len(received) < len(self.sysids):
|
||||||
|
msg = self.mav.recv_match(type='GLOBAL_POSITION_INT', blocking=True, timeout=1)
|
||||||
|
if msg is None:
|
||||||
|
continue
|
||||||
|
sid = msg.get_srcSystem()
|
||||||
|
if sid in self.sysids:
|
||||||
|
drone_id = f"s0_{sid}"
|
||||||
|
self.drone_gps[drone_id] = {
|
||||||
|
'lat': msg.lat / 1e7,
|
||||||
|
'lon': msg.lon / 1e7,
|
||||||
|
'alt': msg.relative_alt / 1000.0
|
||||||
|
}
|
||||||
|
received.add(sid)
|
||||||
|
|
||||||
|
for sid in self.sysids:
|
||||||
|
drone_id = f"s0_{sid}"
|
||||||
|
if drone_id in self.drone_gps:
|
||||||
|
gps = self.drone_gps[drone_id]
|
||||||
|
print(f" {drone_id}: ({gps['lat']:.6f}, {gps['lon']:.6f}, {gps['alt']:.1f}m)")
|
||||||
|
else:
|
||||||
|
print(f" {drone_id}: 尚未收到 GPS")
|
||||||
|
|
||||||
|
def start_gps_polling(self, interval_ms=200):
|
||||||
|
"""啟動定時 GPS 輪詢 (用 QTimer)"""
|
||||||
|
self._gps_timer = QTimer()
|
||||||
|
self._gps_timer.timeout.connect(self._poll_gps)
|
||||||
|
self._gps_timer.start(interval_ms)
|
||||||
|
|
||||||
|
def _poll_gps(self):
|
||||||
|
"""非阻塞方式讀取最新 GPS"""
|
||||||
|
while True:
|
||||||
|
msg = self.mav.recv_match(type='GLOBAL_POSITION_INT', blocking=False)
|
||||||
|
if msg is None:
|
||||||
|
break
|
||||||
|
sid = msg.get_srcSystem()
|
||||||
|
if sid in self.sysids:
|
||||||
|
drone_id = f"s0_{sid}"
|
||||||
|
self.drone_gps[drone_id] = {
|
||||||
|
'lat': msg.lat / 1e7,
|
||||||
|
'lon': msg.lon / 1e7,
|
||||||
|
'alt': msg.relative_alt / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 建立 Qt 應用 (MissionExecutor 需要 QTimer)
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
|
# 連線 + 起飛前置作業
|
||||||
|
manager = SITLDroneManager(RECV_CONNECTION, DRONE_SYSIDS)
|
||||||
|
manager.connect()
|
||||||
|
|
||||||
|
for sysid in DRONE_SYSIDS:
|
||||||
|
manager.set_guided_and_arm(sysid)
|
||||||
|
manager.takeoff(sysid, TAKEOFF_ALT)
|
||||||
|
|
||||||
|
# 等待所有無人機到達起飛高度
|
||||||
|
for sysid in DRONE_SYSIDS:
|
||||||
|
manager.wait_for_altitude(sysid, TAKEOFF_ALT)
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# 讀取當前 GPS 位置
|
||||||
|
print("\n讀取當前 GPS 位置 ...")
|
||||||
|
manager.update_gps_once()
|
||||||
|
|
||||||
|
drone_ids = [f"s0_{sid}" for sid in DRONE_SYSIDS]
|
||||||
|
drone_gps_positions = []
|
||||||
|
for drone_id in drone_ids:
|
||||||
|
gps = manager.drone_gps.get(drone_id)
|
||||||
|
if gps is None:
|
||||||
|
print(f"錯誤: 讀不到 {drone_id} 的 GPS")
|
||||||
|
return
|
||||||
|
drone_gps_positions.append((gps['lat'], gps['lon'], gps['alt']))
|
||||||
|
|
||||||
|
# 規劃任務
|
||||||
|
print(f"\n規劃任務 (模式: {TEST_MODE}) ...")
|
||||||
|
planner = FormationPlanner(
|
||||||
|
spacing=FORMATION_SPACING,
|
||||||
|
base_altitude=BASE_ALTITUDE,
|
||||||
|
altitude_diff=ALTITUDE_DIFF
|
||||||
|
)
|
||||||
|
|
||||||
|
center_lat = drone_gps_positions[0][0]
|
||||||
|
center_lon = drone_gps_positions[0][1]
|
||||||
|
|
||||||
|
if TEST_MODE == "formation":
|
||||||
|
target_lat = center_lat + 30.0 / 111000.0
|
||||||
|
target_lon = center_lon
|
||||||
|
target_gps = (target_lat, target_lon, BASE_ALTITUDE)
|
||||||
|
print(f" 目標點: ({target_lat:.6f}, {target_lon:.6f})")
|
||||||
|
|
||||||
|
waypoints_per_drone, origin = planner.plan_formation_mission(
|
||||||
|
drone_gps_positions, target_gps, MissionType.M_FORMATION
|
||||||
|
)
|
||||||
|
|
||||||
|
elif TEST_MODE == "grid_sweep":
|
||||||
|
# 在無人機前方 30m 處建立 40m x 30m 的矩形
|
||||||
|
offset_lat = 30.0 / 111000.0
|
||||||
|
half_w = 20.0 / (111000.0 * 0.9)
|
||||||
|
half_h = 15.0 / 111000.0
|
||||||
|
|
||||||
|
rect_center_lat = center_lat + offset_lat
|
||||||
|
rect_center_lon = center_lon
|
||||||
|
|
||||||
|
rect_corners = [
|
||||||
|
(rect_center_lat - half_h, rect_center_lon - half_w),
|
||||||
|
(rect_center_lat - half_h, rect_center_lon + half_w),
|
||||||
|
(rect_center_lat + half_h, rect_center_lon + half_w),
|
||||||
|
(rect_center_lat + half_h, rect_center_lon - half_w),
|
||||||
|
]
|
||||||
|
target_gps = (rect_center_lat, rect_center_lon, BASE_ALTITUDE)
|
||||||
|
print(f" 矩形中心: ({rect_center_lat:.6f}, {rect_center_lon:.6f})")
|
||||||
|
|
||||||
|
waypoints_per_drone, origin = planner.plan_formation_mission(
|
||||||
|
drone_gps_positions, target_gps, MissionType.GRID_SWEEP,
|
||||||
|
params={
|
||||||
|
'rect_corners': rect_corners,
|
||||||
|
'line_spacing': GRID_LINE_SPACING,
|
||||||
|
'altitude': BASE_ALTITUDE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"未知測試模式: {TEST_MODE}")
|
||||||
|
return
|
||||||
|
|
||||||
|
planned_waypoints = {
|
||||||
|
'drone_ids': drone_ids,
|
||||||
|
'waypoints': waypoints_per_drone
|
||||||
|
}
|
||||||
|
|
||||||
|
# 印出規劃結果
|
||||||
|
for i, did in enumerate(drone_ids):
|
||||||
|
wps = waypoints_per_drone[i]
|
||||||
|
print(f" {did}: {len(wps)} 個航點")
|
||||||
|
for j, wp in enumerate(wps):
|
||||||
|
print(f" WP{j}: ({wp[0]:.6f}, {wp[1]:.6f}, {wp[2]:.1f}m)")
|
||||||
|
|
||||||
|
# 啟動任務
|
||||||
|
print("\n啟動任務 ...")
|
||||||
|
manager.start_gps_polling(interval_ms=200)
|
||||||
|
|
||||||
|
sender = MavlinkSender(SEND_CONNECTION)
|
||||||
|
executor = MissionExecutor(
|
||||||
|
sender=sender,
|
||||||
|
drone_gps=manager.drone_gps,
|
||||||
|
arrival_radius=ARRIVAL_RADIUS,
|
||||||
|
send_rate_hz=2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
executor.drone_waypoint_reached.connect(
|
||||||
|
lambda did, idx, total: print(f"\n >> {did} 到達 WP {idx}/{total}")
|
||||||
|
)
|
||||||
|
executor.mission_completed.connect(
|
||||||
|
lambda: (print("\n===== 任務全部完成 ====="), app.quit())
|
||||||
|
)
|
||||||
|
|
||||||
|
# 設定超時自動退出
|
||||||
|
timeout_timer = QTimer()
|
||||||
|
timeout_timer.setSingleShot(True)
|
||||||
|
timeout_timer.timeout.connect(lambda: (
|
||||||
|
print("\n⚠ 測試超時,強制退出"),
|
||||||
|
executor.stop(),
|
||||||
|
app.quit()
|
||||||
|
))
|
||||||
|
timeout_timer.start(180_000) # 180 秒超時
|
||||||
|
|
||||||
|
executor.start(planned_waypoints)
|
||||||
|
|
||||||
|
print("進入事件迴圈 (等待任務完成或 180 秒超時) ...\n")
|
||||||
|
app.exec()
|
||||||
|
|
||||||
|
executor.stop()
|
||||||
|
sender.close()
|
||||||
|
print("測試結束")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue