diff --git a/src/unitdev01/unitdev01/gui.py b/src/unitdev01/unitdev01/gui.py index 6636fa3..5bc7213 100644 --- a/src/unitdev01/unitdev01/gui.py +++ b/src/unitdev01/unitdev01/gui.py @@ -4,7 +4,7 @@ from rclpy.node import Node from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QWidget, QLabel, QSplitter, QScrollArea, QSizePolicy, QTabWidget, QTableWidget, QTableWidgetItem, - QHeaderView, QPushButton, QCheckBox) + QHeaderView, QPushButton, QCheckBox, QLineEdit) from PyQt6.QtCore import Qt, QTimer, QObject, pyqtSignal from PyQt6.QtWebEngineWidgets import QWebEngineView import math @@ -18,6 +18,9 @@ from mavros_msgs.msg import State, VfrHud from mavros_msgs.srv import CommandBool, CommandTOL, SetMode import asyncio from functools import partial +import threading +import websockets +import json class DroneSignals(QObject): update_signal = pyqtSignal(str, str, object) # (msg_type, drone_id, data) @@ -31,14 +34,99 @@ class DroneMonitor(Node): self.arm_clients = {} self.takeoff_clients = {} + self.setpoint_pubs = {} self.selected_drones = set() + self.latest_data = {} + + # 啟動 WebSocket client 執行緒 + threading.Thread(target=self.start_ws_client, daemon=True).start() # 主题检测定时器 self.create_timer(1.0, self.scan_topics) + + def start_ws_client(self): + asyncio.set_event_loop(asyncio.new_event_loop()) + asyncio.get_event_loop().run_until_complete(self.ws_client_loop()) + + async def ws_client_loop(self): + retry_count = 0 + max_retries = 5 + base_delay = 1.0 + local = "ws://0.0.0.0:8765" # 本地 WebSocket 地址 + remote = "ws://140.120.31.123:8765" + while retry_count < max_retries: + try: + async with websockets.connect(local) as websocket: + print("WebSocket connected") + retry_count = 0 # 重置重試計數 + + async for message in websocket: + try: + data = json.loads(message) + print(f"📡 Received: {data}") + self.process_websocket_message(data) + except json.JSONDecodeError as e: + print(f"WebSocket JSON decode error: {e}") + except Exception as e: + print(f"WebSocket message processing error: {e}") + + except websockets.exceptions.ConnectionClosedError: + print("WebSocket connection closed") + break + except Exception as e: + retry_count += 1 + delay = base_delay * (2 ** min(retry_count, 4)) # 指數退避 + print(f"WebSocket connection error: {e}, retrying in {delay}s (attempt {retry_count}/{max_retries})") + await asyncio.sleep(delay) + print("WebSocket client stopped after maximum retries") + + + def process_websocket_message(self, data): + try: + drone_id = data.get('system_id') + drone_id = f"s9_{drone_id}" if drone_id else None + if not drone_id: + return + + # 模式 + if 'mode' in data: + self.signals.update_signal.emit('state', drone_id, { + 'mode': data['mode'], + }) + + # 電池 + if 'battery' in data: + # 這裡假設 battery 是百分比 + self.signals.update_signal.emit('battery', drone_id, { + 'percentage': data['battery'] + }) + + # 位置 + if 'position' in data: + pos = data['position'] + self.signals.update_signal.emit('gps', drone_id, { + 'lat': pos.get('lat', 0), + 'lon': pos.get('lon', 0) + }) + + self.signals.update_signal.emit('local_pose', drone_id, { + 'x': round(pos.get('lat', 0), 6), + 'y': round(pos.get('lon', 0), 6), + }) + + # 航向 + if 'heading' in data: + self.signals.update_signal.emit('hud', drone_id, { + 'heading': data['heading'], + }) + + except Exception as e: + print(f"WebSocket message processing error: {e}") + def scan_topics(self): topics = self.get_topic_names_and_types() - drone_pattern = re.compile(r'/MavLinkBus/(s\d+)/(\w+)') + drone_pattern = re.compile(r'/MavLinkBus/(s\d+_\d+)/(\w+)') found_drones = set() for topic_name, _ in topics: @@ -55,7 +143,6 @@ class DroneMonitor(Node): def setup_drone(self, drone_id): base_topic = f'/MavLinkBus/{drone_id}' - # Add service clients self.arm_clients[drone_id] = self.create_client( CommandBool, @@ -65,7 +152,14 @@ class DroneMonitor(Node): CommandTOL, f'{base_topic}/cmd/takeoff' ) - + + # Add setpoint publisher + self.setpoint_pubs[drone_id] = self.create_publisher( + Point, + f'{base_topic}/setpoint_position/local', + 10 + ) + subs = { 'attitude': self.create_subscription( Imu, @@ -130,7 +224,6 @@ class DroneMonitor(Node): } setattr(self, f'drone_{drone_id}_subs', subs) - self.signals.update_signal.emit('new_drone', drone_id, None) async def arm_drone(self, drone_id, arm): if drone_id not in self.arm_clients: @@ -171,7 +264,20 @@ class DroneMonitor(Node): except Exception as e: self.get_logger().error(f'Takeoff service call failed: {e}') return False + + def send_setpoint(self, drone_id, x, y, z): + """Send setpoint position command""" + if drone_id not in self.setpoint_pubs: + return False + + msg = Point() + msg.x = float(x) + msg.y = float(y) + msg.z = float(z) + self.setpoint_pubs[drone_id].publish(msg) + return True + def quaternion_to_euler(self, q): sinr_cosp = 2 * (q.w * q.x + q.y * q.z) cosr_cosp = 1 - 2 * (q.x**2 + q.y**2) @@ -190,71 +296,71 @@ class DroneMonitor(Node): def attitude_callback(self, drone_id, msg): if hasattr(msg, 'orientation'): roll, pitch, yaw = self.quaternion_to_euler(msg.orientation) - self.signals.update_signal.emit('attitude', drone_id, { + self.latest_data[(drone_id, 'attitude')] = { 'roll': roll, 'pitch': pitch, 'yaw': yaw, 'rates': (msg.angular_velocity.x, msg.angular_velocity.y, msg.angular_velocity.z) - }) + } def battery_callback(self, drone_id, msg): - self.signals.update_signal.emit('battery', drone_id, { + self.latest_data[(drone_id, 'battery')] = { 'voltage': msg.voltage - }) + } def state_callback(self, drone_id, msg): - self.signals.update_signal.emit('state', drone_id, { + self.latest_data[(drone_id, 'state')] = { 'mode': msg.mode, 'armed': msg.armed - }) + } def gps_callback(self, drone_id, msg): - self.signals.update_signal.emit('gps', drone_id, { + self.latest_data[(drone_id, 'gps')] = { 'lat': msg.latitude, 'lon': msg.longitude, 'alt': msg.altitude - }) + } def local_vel_callback(self, drone_id, msg): - self.signals.update_signal.emit('velocity', drone_id, { + self.latest_data[(drone_id, 'velocity')] = { 'vx': msg.x, 'vy': msg.y, 'vz': msg.z - }) + } def altitude_callback(self, drone_id, msg): - self.signals.update_signal.emit('altitude', drone_id, { + self.latest_data[(drone_id, 'altitude')] = { 'altitude': msg.data - }) + } def local_pose_callback(self, drone_id, msg): - self.signals.update_signal.emit('local_pose', drone_id, { + self.latest_data[(drone_id, 'local_pose')] = { 'x': msg.x, 'y': msg.y, 'z': msg.z - }) + } def hud_callback(self, drone_id, msg): - self.signals.update_signal.emit('hud', drone_id, { + self.latest_data[(drone_id, 'hud')] = { 'airspeed': msg.airspeed, 'groundspeed': msg.groundspeed, 'heading': msg.heading, 'throttle': msg.throttle, 'alt': msg.altitude, 'climb': msg.climb - }) + } def loss_rate_callback(self, drone_id, msg): - self.signals.update_signal.emit('loss_rate', drone_id, { + self.latest_data[(drone_id, 'loss_rate')] = { 'loss_rate': msg.data - }) + } def ping_callback(self, drone_id, msg): - self.signals.update_signal.emit('ping', drone_id, { + self.latest_data[(drone_id, 'ping')] = { 'ping': msg.data - }) + } class ControlStationUI(QMainWindow): def __init__(self): @@ -271,8 +377,14 @@ class ControlStationUI(QMainWindow): self.executor = rclpy.executors.SingleThreadedExecutor() self.executor.add_node(self.monitor) + # 定时处理ROS事件 + self.timer = QTimer() + self.timer.timeout.connect(self.spin_ros) + self.timer.start(10) + # 初始化UI self.drones = {} + self.socket_groups = {} # 新增:用於儲存 socket 分組 self.info_types = ["模式", "ARM", "電壓", "高度", "Local", "Velocity", "地速", "航向", "Roll", "Pitch", "Yaw", "丟包", "延遲"] self.info_type_map = { @@ -293,12 +405,15 @@ class ControlStationUI(QMainWindow): 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) # 每 200ms 更新一次地圖 + # 添加待更新的無人機位置緩存 + self.pending_map_updates = {} + self.init_ui() - # 定时处理ROS事件 - self.timer = QTimer() - self.timer.timeout.connect(self.spin_ros) - self.timer.start(50) def init_ui(self): @@ -323,7 +438,7 @@ class ControlStationUI(QMainWindow): # 底部控制按鈕區域 bottom_control = QWidget() - bottom_layout = QHBoxLayout(bottom_control) + bottom_layout = QVBoxLayout(bottom_control) select_all_btn = QPushButton("全選") select_all_btn.clicked.connect(self.handle_select_all) @@ -367,10 +482,82 @@ class ControlStationUI(QMainWindow): QPushButton:hover { background-color: #555; } """) - bottom_layout.addWidget(select_all_btn) - bottom_layout.addWidget(arm_all_btn) - bottom_layout.addWidget(takeoff_all_btn) - bottom_layout.addStretch() + # Add coordinate inputs + coord_widget = QWidget() + coord_layout = QHBoxLayout(coord_widget) + coord_layout.setContentsMargins(0, 0, 0, 0) + + self.x_input = QLineEdit() + self.y_input = QLineEdit() + self.z_input = QLineEdit() + + for input_field in [self.x_input, self.y_input, self.z_input]: + input_field.setFixedWidth(60) + input_field.setStyleSheet(""" + QLineEdit { + background-color: #333; + color: #DDD; + border: 1px solid #555; + border-radius: 4px; + padding: 3px; + } + """) + + coord_layout.addWidget(QLabel("X:")) + coord_layout.addWidget(self.x_input) + coord_layout.addWidget(QLabel("Y:")) + coord_layout.addWidget(self.y_input) + coord_layout.addWidget(QLabel("Z:")) + coord_layout.addWidget(self.z_input) + + # Add buttons to control layout + setpoint_btn = QPushButton("Setpoint") + setpoint_btn.clicked.connect(self.handle_setpoint_selected) + setpoint_btn.setStyleSheet(""" + QPushButton { + background-color: #444; + color: #DDD; + border: none; + padding: 5px 10px; + border-radius: 4px; + min-width: 80px; + } + QPushButton:hover { background-color: #555; } + """) + + # 上方按鈕區域 + upper_buttons = QHBoxLayout() + select_all_btn = QPushButton("全選") + select_all_btn.clicked.connect(self.handle_select_all) + arm_all_btn = QPushButton("批次解鎖") + arm_all_btn.clicked.connect(self.handle_arm_selected) + takeoff_all_btn = QPushButton("批次起飛") + takeoff_all_btn.clicked.connect(self.handle_takeoff_selected) + for btn in [select_all_btn, arm_all_btn, takeoff_all_btn]: + btn.setStyleSheet(""" + QPushButton { + background-color: #444; + color: #DDD; + border: none; + padding: 5px 10px; + border-radius: 4px; + min-width: 80px; + } + QPushButton:hover { background-color: #555; } + """) + upper_buttons.addWidget(btn) + upper_buttons.addStretch() + + # 下方座標輸入區域 + lower_control = QHBoxLayout() + lower_control.addWidget(coord_widget) + lower_control.addWidget(setpoint_btn) + lower_control.addStretch() + + + # 將兩個區域加入底部控制區 + bottom_layout.addLayout(upper_buttons) + bottom_layout.addLayout(lower_control) # — 分頁 2:Overview Table self.overview_table = QTableWidget() @@ -408,9 +595,9 @@ class ControlStationUI(QMainWindow):