diff --git a/src/unitdev01/unitdev01/comm_panel.py b/src/unitdev01/unitdev01/comm_panel.py new file mode 100644 index 0000000..6259c8d --- /dev/null +++ b/src/unitdev01/unitdev01/comm_panel.py @@ -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) \ No newline at end of file diff --git a/src/unitdev01/unitdev01/communication.py b/src/unitdev01/unitdev01/communication.py index 9584a62..2db56c1 100644 --- a/src/unitdev01/unitdev01/communication.py +++ b/src/unitdev01/unitdev01/communication.py @@ -130,6 +130,129 @@ class UDPMavlinkReceiver(threading.Thread): """停止接收器""" 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): @@ -266,6 +389,9 @@ class DroneMonitor(Node): # WebSocket 接收器列表 self.ws_receivers = [] + # 串口接收器列表 + self.serial_receivers = [] + # 主题检测定时器 self.create_timer(1.0, self.scan_topics) @@ -508,4 +634,13 @@ class DroneMonitor(Node): def ping_callback(self, drone_id, msg): self.latest_data[(drone_id, 'ping')] = { 'ping': msg.data - } \ No newline at end of file + } + + 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 \ No newline at end of file diff --git a/src/unitdev01/unitdev01/drone_panel.py b/src/unitdev01/unitdev01/drone_panel.py new file mode 100644 index 0000000..a5a7bf5 --- /dev/null +++ b/src/unitdev01/unitdev01/drone_panel.py @@ -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) \ No newline at end of file diff --git a/src/unitdev01/unitdev01/gui.py b/src/unitdev01/unitdev01/gui.py index 5eac6a3..615e9ff 100644 --- a/src/unitdev01/unitdev01/gui.py +++ b/src/unitdev01/unitdev01/gui.py @@ -5,12 +5,14 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout QSizePolicy, QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView, QPushButton, QCheckBox, QLineEdit) from PyQt6.QtCore import Qt, QTimer -from PyQt6.QtWebEngineWidgets import QWebEngineView import sys import asyncio -# 從 communication.py 導入分離的類別 +# 導入分離的類別 from communication import DroneMonitor, UDPMavlinkReceiver, WebSocketMavlinkReceiver +from map_layout import DroneMap +from drone_panel import DronePanel, SocketGroupPanel +from comm_panel import CommPanel class ControlStationUI(QMainWindow): def __init__(self): @@ -52,26 +54,30 @@ class ControlStationUI(QMainWindow): "loss_rate": 11, "ping": 12 } + self.socket_colors = { - '0': '#00BFFF', - '1': '#FFD700', - '2': "#FF6969", - '8': '#7CFC00', - '9': '#FF8C00', - 'default': '#AAAAAA' + '0': '#00BFFF', # 天藍色 (DeepSkyBlue) + '1': '#FFD700', # 金色 (Gold) + '2': '#FF6969', # 淺紅色 (Light Red) + '3': '#FF69B4', # 熱粉紅 (HotPink) + '4': '#00FA9A', # 中春綠 (MediumSpringGreen) + '5': '#9370DB', # 中紫色 (MediumPurple) - 串口 + '6': '#FFA500', # 橙色 (Orange) + '7': '#20B2AA', # 淺海綠 (LightSeaGreen) + '8': '#7CFC00', # 草綠色 (LawnGreen) + '9': '#FF8C00', # 深橙色 (DarkOrange) + 'default': '#AAAAAA' # 灰色 } + self.drone_positions = {} self.drone_headings = {} - self.map_loaded = False - self.map_update_timer = QTimer() - self.map_update_timer.timeout.connect(self.update_map_positions) - self.map_update_timer.start(200) - self.pending_map_updates = {} - + # 初始化地圖 + self.drone_map = DroneMap() # 初始化連接列表 self.udp_receivers = [] self.udp_connections = [] self.ws_connections = [] + self.monitor.start_serial_connection('/dev/ttyUSB0', 57600) self.init_ui() @@ -112,147 +118,16 @@ class ControlStationUI(QMainWindow): self.left_tab.addTab(self.overview_table, "總覽") # — 分頁 3:通訊設定 - self.comm_tab = QWidget() - comm_layout = QVBoxLayout(self.comm_tab) - comm_layout.setContentsMargins(10, 10, 10, 10) - comm_layout.setSpacing(10) - - # ========== UDP MAVLink 區域 ========== - udp_title = QLabel("UDP MAVLink") - udp_title.setStyleSheet(""" - color: #DDD; - font-size: 14px; - font-weight: bold; - padding: 5px; - background-color: #333; - border-radius: 4px; - """) - comm_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) - comm_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.comm_panel = CommPanel() + self.comm_panel.udp_connection_added.connect(self.handle_udp_connection_added) + self.comm_panel.ws_connection_added.connect(self.handle_ws_connection_added) + self.comm_panel.udp_connection_toggled.connect(self.toggle_udp_connection) + self.comm_panel.ws_connection_toggled.connect(self.toggle_ws_connection) + self.comm_panel.udp_connection_removed.connect(self.remove_udp_connection) + self.comm_panel.ws_connection_removed.connect(self.remove_ws_connection) + self.comm_panel.status_message.connect(lambda msg, timeout: self.statusBar().showMessage(msg, timeout)) - 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_connection) - 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) - - comm_layout.addWidget(add_udp_widget) - - # 分隔線 - separator = QWidget() - separator.setFixedHeight(20) - comm_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; - """) - comm_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) - comm_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_connection) - 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) - - comm_layout.addWidget(add_ws_widget) - comm_layout.addStretch() - - self.left_tab.addTab(self.comm_tab, "通訊") + self.left_tab.addTab(self.comm_panel, "通訊") # 右侧容器 right_container = QWidget() @@ -384,647 +259,54 @@ class ControlStationUI(QMainWindow): right_layout.addLayout(batch_control_layout) # 添加地圖 - self.map_view = QWebEngineView() - - inline_html = ''' - - - - - - - - - - -
-
- -
- - - - - ''' - self.map_view.setHtml(inline_html) - self.map_view.loadFinished.connect(self.on_map_loaded) - - right_layout.addWidget(self.map_view) + right_layout.addWidget(self.drone_map.get_widget()) + self.drone_map.get_gps_signal().connect(self.handle_map_click) + # Add widgets to splitter main_splitter.addWidget(self.left_tab) main_splitter.addWidget(right_container) main_splitter.setSizes([400, 1000]) self.setCentralWidget(main_splitter) - def on_map_loaded(self, ok: bool): - if ok: - self.map_loaded = True - else: - print("⚠️ 地图页加载失败") - - # 修改2: 添加地圖更新節流方法 - 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 create_drone_panel(self, drone_id): - panel = QWidget() - panel.setObjectName(f"panel_{drone_id}") - panel.setFixedHeight(140) # 根據需要調整高度 - panel.setStyleSheet(""" - QWidget#panel_%s { - background-color: #2A2A2A; - border-radius: 8px; - margin: 6px; - padding: 10px; - border: 1px solid #444; - } - QLabel { - color: #DDD; - font-size: 12px; - padding: 2px; - } - """ % drone_id) - - # 主佈局改為水平佈局 - main_layout = QHBoxLayout(panel) - main_layout.setContentsMargins(8, 8, 8, 8) - main_layout.setSpacing(8) - - # 左側資訊區域 - 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) - - # 在標題列加入勾選框 - checkbox = QCheckBox() - checkbox.setObjectName(f"{drone_id}_checkbox") - checkbox.setStyleSheet("QCheckBox { color: #DDD; }") - checkbox.stateChanged.connect(lambda state: self.handle_drone_selection(drone_id, state)) - - header_layout.insertWidget(0, checkbox) # 插入到最前面 - - # ID显示 - 修改這裡:將 s0_4 格式改為 s4 格式 - display_id = 's' + drone_id.split('_')[1] - id_label = QLabel(display_id) - id_label.setStyleSheet(""" - font-weight: bold; - font-size: 14px; - color: #7FFFD4; - min-width: 80px; - """) - - header_layout.addWidget(id_label) - header_layout.addStretch() - - info_layout.addWidget(header) - - # 第一行:狀態 (模式 + ARM狀態) - 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;") - - mode_label = QLabel("--") - mode_label.setObjectName(f"{drone_id}_mode") - - armed_label = QLabel("--") - armed_label.setObjectName(f"{drone_id}_armed") - - status_layout.addWidget(status_title) - status_layout.addWidget(mode_label) - status_layout.addWidget(armed_label) - status_layout.addStretch() - - info_layout.addWidget(status_row) - - # 第二行:電池 - 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;") - - battery_label = QLabel("--") - battery_label.setObjectName(f"{drone_id}_battery") - - battery_layout.addWidget(battery_title) - battery_layout.addWidget(battery_label) - battery_layout.addStretch() - - info_layout.addWidget(battery_row) - - # 第三行:位置 + 高度 - 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;") - - local_label = QLabel("--") - local_label.setObjectName(f"{drone_id}_local") - - altitude_title = QLabel("高度:") - altitude_title.setStyleSheet("color: #888; margin-left: 10px;") - - altitude_label = QLabel("--") - altitude_label.setObjectName(f"{drone_id}_altitude") - - position_layout.addWidget(position_title) - position_layout.addWidget(local_label) - position_layout.addWidget(altitude_title) - position_layout.addWidget(altitude_label) - position_layout.addStretch() - - info_layout.addWidget(position_row) - - # 第四行:航向 + 速度 - 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;") - - heading_label = QLabel("--") - heading_label.setObjectName(f"{drone_id}_heading") - - speed_title = QLabel("速度:") - speed_title.setStyleSheet("color: #888; margin-left: 10px;") - - groundspeed_label = QLabel("--") - groundspeed_label.setObjectName(f"{drone_id}_groundspeed") - - nav_layout.addWidget(heading_title) - nav_layout.addWidget(heading_label) - nav_layout.addWidget(speed_title) - nav_layout.addWidget(groundspeed_label) - nav_layout.addStretch() - - info_layout.addWidget(nav_row) - - # 右側控制按鈕區域 - control_widget = QWidget() - control_layout = QVBoxLayout(control_widget) - control_layout.setContentsMargins(0, 0, 0, 0) - control_layout.setSpacing(6) - - # 設置控制區域的固定寬度 - control_widget.setFixedWidth(80) - - mode_btn = QPushButton("切換模式") - mode_btn.setObjectName(f"{drone_id}_mode_btn") - mode_btn.clicked.connect(lambda: self.handle_mode_change(drone_id)) - mode_btn.setStyleSheet(""" - QPushButton { - background-color: #444; - color: #DDD; - border: none; - padding: 8px 6px; - border-radius: 4px; - font-size: 11px; - } - QPushButton:hover { - background-color: #555; - } - """) - - arm_btn = QPushButton("解鎖") - arm_btn.setObjectName(f"{drone_id}_arm_btn") - arm_btn.clicked.connect(lambda: self.handle_arm(drone_id)) - arm_btn.setStyleSheet(""" - QPushButton { - background-color: #444; - color: #DDD; - border: none; - padding: 8px 6px; - border-radius: 4px; - font-size: 11px; - } - QPushButton:hover { - background-color: #555; - } - """) - - takeoff_btn = QPushButton("起飛") - takeoff_btn.setObjectName(f"{drone_id}_takeoff_btn") - takeoff_btn.clicked.connect(lambda: self.handle_takeoff(drone_id)) - takeoff_btn.setStyleSheet(""" - QPushButton { - background-color: #444; - color: #DDD; - border: none; - padding: 8px 6px; - border-radius: 4px; - font-size: 11px; - } - QPushButton:hover { - background-color: #555; - } - """) - - setpoint_btn = QPushButton("Setpoint") - setpoint_btn.setObjectName(f"{drone_id}_setpoint_btn") - setpoint_btn.clicked.connect(lambda: self.handle_single_setpoint(drone_id)) - setpoint_btn.setStyleSheet(""" - QPushButton { - background-color: #444; - color: #DDD; - border: none; - padding: 8px 6px; - border-radius: 4px; - font-size: 11px; - } - QPushButton:hover { - background-color: #555; - } - """) - - # 按鈕由上而下排列 - control_layout.addWidget(mode_btn) - control_layout.addWidget(arm_btn) - control_layout.addWidget(takeoff_btn) - control_layout.addWidget(setpoint_btn) - control_layout.addStretch() # 將按鈕推向頂部 - - # 將左側資訊區域和右側控制區域加入主佈局 - main_layout.addWidget(info_widget) - main_layout.addWidget(control_widget) - - return panel - 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.toggle_udp_connection(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.remove_udp_connection(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.toggle_ws_connection(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.remove_ws_connection(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 handle_map_click(self, lat, lon): + print(f"地圖點擊位置: {lat:.6f}, {lon:.6f}") - def handle_add_ws_connection(self): - """處理添加 WebSocket 連接""" - input_url = self.ws_url_input.text().strip() - - if not input_url: - self.statusBar().showMessage("請輸入 WebSocket URL", 3000) - return + def handle_udp_connection_added(self, ip, port): + """处理 UDP 连接添加信号""" + # 创建新连接 + new_conn = { + 'name': f'UDP {len(self.udp_connections) + 1}', + 'ip': ip, + 'port': port, + 'enabled': True + } - # 自動添加 ws:// 前綴(如果用戶沒有輸入協議前綴) - if not input_url.startswith('ws://') and not input_url.startswith('wss://'): - url = f'ws://{input_url}' - else: - url = input_url + # 启动接收器 + receiver = UDPMavlinkReceiver(ip, port, self.monitor.signals, new_conn['name']) + receiver.start() + self.udp_receivers.append(receiver) + new_conn['receiver'] = receiver - # 基本 URL 格式驗證 - try: - # 檢查是否包含 host:port 格式 - if '://' in url: - parts = url.split('://', 1) - if len(parts) == 2 and ':' not in parts[1]: - self.statusBar().showMessage("URL 格式錯誤,需要包含端口號 (例如: 127.0.0.1:8756)", 3000) - return - except: - self.statusBar().showMessage("URL 格式錯誤", 3000) - return + self.udp_connections.append(new_conn) - # 檢查是否已存在相同連接 - for conn in self.ws_connections: - if conn['url'] == url: - self.statusBar().showMessage("連接已存在", 3000) - return + # 添加到 UI + self.comm_panel.add_udp_panel(new_conn) - # 創建新連接 + self.statusBar().showMessage(f"已添加 UDP 连接: {ip}:{port}", 3000) + print(f"UDP MAVLink receiver added and started on {ip}:{port}") + + def handle_ws_connection_added(self, url): + """处理 WebSocket 连接添加信号""" + # 创建新连接 new_conn = { 'name': f'WS {len(self.ws_connections) + 1}', 'url': url, 'enabled': True } - # 啟動接收器 + # 启动接收器 receiver = WebSocketMavlinkReceiver(url, self.monitor.signals, new_conn['name']) receiver.start() self.monitor.ws_receivers.append(receiver) @@ -1033,14 +315,23 @@ class ControlStationUI(QMainWindow): self.ws_connections.append(new_conn) # 添加到 UI - conn_panel = self.create_ws_connection_panel(new_conn) - self.ws_list_layout.addWidget(conn_panel) - - # 清空輸入框 - self.ws_url_input.clear() + self.comm_panel.add_ws_panel(new_conn) - self.statusBar().showMessage(f"已添加 WebSocket 連接: {url}", 3000) + self.statusBar().showMessage(f"已添加 WebSocket 连接: {url}", 3000) print(f"WebSocket receiver added and started: {url}") + + def create_drone_panel(self, drone_id): + """創建無人機面板""" + panel = DronePanel(drone_id) + + # 連接信號 + panel.mode_change_requested.connect(self.handle_mode_change) + panel.arm_requested.connect(self.handle_arm) + panel.takeoff_requested.connect(self.handle_takeoff) + panel.setpoint_requested.connect(self.handle_single_setpoint) + panel.selection_changed.connect(self.handle_drone_selection) + + return panel def toggle_ws_connection(self, conn, btn, status_label): """切換 WebSocket 連接狀態""" @@ -1066,72 +357,25 @@ class ControlStationUI(QMainWindow): self.statusBar().showMessage(f"已啟動 WebSocket 連接: {conn['url']}", 3000) def remove_ws_connection(self, conn, panel): - """移除 WebSocket 連接""" + """移除 WebSocket 连接""" # 停止接收器 if 'receiver' in conn and conn['receiver']: conn['receiver'].stop() if conn['receiver'] in self.monitor.ws_receivers: self.monitor.ws_receivers.remove(conn['receiver']) - # 從列表移除 + # 从列表移除 if conn in self.ws_connections: self.ws_connections.remove(conn) - # 從 UI 移除 + # 从 comm_panel 列表移除 + self.comm_panel.remove_ws_connection_from_list(conn) + + # 从 UI 移除 panel.setParent(None) panel.deleteLater() - self.statusBar().showMessage(f"已移除 WebSocket 連接: {conn['url']}", 3000) - - def handle_add_udp_connection(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.statusBar().showMessage("請輸入 IP 和 Port", 3000) - return - - try: - port = int(port_text) - if port < 1 or port > 65535: - raise ValueError("Port 超出範圍") - except ValueError: - self.statusBar().showMessage("Port 必須是 1-65535 的數字", 3000) - return - - # 檢查是否已存在相同連接 - for conn in self.udp_connections: - if conn['ip'] == ip and conn['port'] == port: - self.statusBar().showMessage("連接已存在", 3000) - return - - # 創建新連接 - new_conn = { - 'name': f'UDP {len(self.udp_connections) + 1}', - 'ip': ip, - 'port': port, - 'enabled': True - } - - # 啟動接收器 - receiver = UDPMavlinkReceiver(ip, port, self.monitor.signals, new_conn['name']) - receiver.start() - self.udp_receivers.append(receiver) - new_conn['receiver'] = receiver - - self.udp_connections.append(new_conn) - - # 添加到 UI - conn_panel = self.create_udp_connection_panel(new_conn) - self.udp_list_layout.addWidget(conn_panel) - - # 清空輸入框 - self.udp_ip_input.clear() - self.udp_port_input.clear() - - self.statusBar().showMessage(f"已添加 UDP 連接: {ip}:{port}", 3000) - print(f"UDP MAVLink receiver added and started on {ip}:{port}") + self.statusBar().showMessage(f"已移除 WebSocket 连接: {conn['url']}", 3000) def toggle_udp_connection(self, conn, btn, status_label): """切換 UDP 連接狀態""" @@ -1157,109 +401,32 @@ class ControlStationUI(QMainWindow): self.statusBar().showMessage(f"已啟動 UDP 連接: {conn['ip']}:{conn['port']}", 3000) def remove_udp_connection(self, conn, panel): - """移除 UDP 連接""" + """移除 UDP 连接""" # 停止接收器 if 'receiver' in conn and conn['receiver']: conn['receiver'].stop() if conn['receiver'] in self.udp_receivers: self.udp_receivers.remove(conn['receiver']) - # 從列表移除 + # 从列表移除 if conn in self.udp_connections: self.udp_connections.remove(conn) - # 從 UI 移除 + # 从 comm_panel 列表移除 + self.comm_panel.remove_udp_connection_from_list(conn) + + # 从 UI 移除 panel.setParent(None) panel.deleteLater() - self.statusBar().showMessage(f"已移除 UDP 連接: {conn['ip']}:{conn['port']}", 3000) + self.statusBar().showMessage(f"已移除 UDP 连接: {conn['ip']}:{conn['port']}", 3000) def create_socket_group_panel(self, socket_id): """創建 socket 分組面板""" - group_panel = QWidget() - group_panel.setObjectName(f"socket_group_{socket_id}") - group_panel.setStyleSheet(f""" - QWidget#socket_group_{socket_id} {{ - background-color: #1E1E1E; - border: 2px solid #555; - border-radius: 12px; - margin: 8px; - padding: 12px; - }} - QLabel {{ - color: #DDD; - font-size: 12px; - padding: 2px; - }} - """) - - layout = QVBoxLayout(group_panel) - 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) - - # 分組勾選框 - group_checkbox = QCheckBox() - group_checkbox.setObjectName(f"socket_{socket_id}_checkbox") - group_checkbox.setStyleSheet("QCheckBox { color: #DDD; }") - group_checkbox.stateChanged.connect(lambda state: self.handle_group_selection(socket_id, state)) - - # Socket 分組標題 color = self.socket_colors.get(socket_id, self.socket_colors['default']) - title_label = QLabel(f"Socket {socket_id}") - title_label.setStyleSheet(f""" - font-weight: bold; - font-size: 16px; - color: {color}; - margin-bottom: 8px; - padding: 4px 8px; - background-color: #333; - border-radius: 6px; - """) - - title_layout.addWidget(group_checkbox) - title_layout.addWidget(title_label) - title_layout.addStretch() - - layout.addWidget(title_row) - - # 創建子容器用於放置該 socket 下的所有無人機面板 - drones_container = QWidget() - drones_layout = QVBoxLayout(drones_container) - drones_layout.setContentsMargins(0, 0, 0, 0) - drones_layout.setSpacing(4) - - layout.addWidget(drones_container) - - # 儲存容器的引用以便後續添加無人機 - group_panel.drones_container = drones_container - group_panel.drones_layout = drones_layout - - return group_panel - - def create_data_row(self, layout, title, object_name, default): - row = QWidget() - hbox = QHBoxLayout(row) - hbox.setContentsMargins(0, 0, 0, 0) - - # 标题标签 - title_label = QLabel(title) - title_label.setStyleSheet("color: #888; min-width: 80px;") - title_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) - - # 数据标签 - value_label = QLabel(default) - value_label.setObjectName(object_name) - value_label.setWordWrap(True) - value_label.setStyleSheet("margin-left: 10px;") - - hbox.addWidget(title_label) - hbox.addWidget(value_label) - layout.addWidget(row) + panel = SocketGroupPanel(socket_id, color) + panel.group_selection_changed.connect(self.handle_group_selection) + return panel def handle_mode_change(self, drone_id): """處理單個無人機的模式切換""" @@ -1436,11 +603,11 @@ class ControlStationUI(QMainWindow): self.update_overview_table(drone_id, 'pitch', pitch_text) self.update_overview_table(drone_id, 'yaw', yaw_text) - # 新的代碼 - 將地圖更新放入緩存,等待批量處理: + # 更新地圖上的無人機位置 if drone_id in self.drone_positions and drone_id in self.drone_headings: lat, lon = self.drone_positions[drone_id] heading = self.drone_headings[drone_id] - self.pending_map_updates[drone_id] = (lat, lon, heading) + self.drone_map.update_drone_position(drone_id, lat, lon, heading) # 新增處理分組勾選的方法 def handle_group_selection(self, socket_id, state): @@ -1453,7 +620,7 @@ class ControlStationUI(QMainWindow): is_checked = state == Qt.CheckState.Checked.value for drone_id in group_drones: - checkbox = self.drones[drone_id].findChild(QCheckBox, f"{drone_id}_checkbox") + checkbox = self.drones[drone_id].get_checkbox() if checkbox: # 暫時斷開信號連接,避免遞迴觸發 checkbox.blockSignals(True) @@ -1489,7 +656,7 @@ class ControlStationUI(QMainWindow): # 檢查該分組內有多少無人機被勾選 checked_count = sum(1 for did in group_drones - if self.drones[did].findChild(QCheckBox, f"{did}_checkbox").isChecked()) + if self.drones[did].is_checked()) # 獲取分組勾選框 if socket_id in self.socket_groups: @@ -1515,7 +682,7 @@ class ControlStationUI(QMainWindow): """處理全選按鈕 - 支援分組結構""" # 檢查是否所有無人機都已被選中 all_selected = all( - self.drones[drone_id].findChild(QCheckBox, f"{drone_id}_checkbox").isChecked() + self.drones[drone_id].is_checked() for drone_id in self.drones ) @@ -1524,9 +691,7 @@ class ControlStationUI(QMainWindow): # 更新所有勾選框狀態(無人機和分組) for drone_id in self.drones: - checkbox = self.drones[drone_id].findChild(QCheckBox, f"{drone_id}_checkbox") - if checkbox: - checkbox.setChecked(new_state) + self.drones[drone_id].set_checked(new_state) # 更新所有分組勾選框狀態 for socket_id in self.socket_groups: @@ -1557,20 +722,10 @@ class ControlStationUI(QMainWindow): future = self.monitor.set_mode(drone_id, mode) loop.create_task(self.handle_service_response(future, f"{drone_id} 切換模式 {mode}")) - def handle_single_mode_change(self, drone_id): - mode = self.mode_combo.currentText() - loop = asyncio.get_event_loop() - future = self.monitor.set_mode(drone_id, mode) - loop.create_task(self.handle_service_response(future, f"{drone_id} 切換模式 {mode}")) - def update_field(self, panel, drone_id, field, text, color=None): - """Update a specific field in the panel - 添加變更檢查""" - if label := panel.findChild(QLabel, f"{drone_id}_{field}"): - # 只有在文字真正改變時才更新 - if label.text() != text: - label.setText(text) - if color: - label.setStyleSheet(f"color: {color};") + """更新面板中的指定欄位""" + if isinstance(panel, DronePanel): + panel.update_field(field, text, color) def update_overview_table(self, drone_id=None, field=None, value=None): # Ensure the widget is available diff --git a/src/unitdev01/unitdev01/map_layout.py b/src/unitdev01/unitdev01/map_layout.py new file mode 100644 index 0000000..11f7171 --- /dev/null +++ b/src/unitdev01/unitdev01/map_layout.py @@ -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 = ''' + + + + + + + + + + + +
+
+ +
+ + + + + ''' + + 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) \ No newline at end of file