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