@ -14,6 +14,8 @@ import subprocess
import time
import traceback
import re
import threading
from concurrent . futures import ThreadPoolExecutor
def _log ( level , message ) :
@ -143,7 +145,9 @@ class ToggleSwitch(QWidget):
class ControlStationUI ( QMainWindow ) :
VERSION = ' 2.2.0 '
planning_finished = pyqtSignal ( object )
VERSION = ' 2.4.1 '
FONT_SCALE_MIN = 70
FONT_SCALE_MAX = 180
FONT_SCALE_DEFAULT = 100
@ -152,6 +156,7 @@ class ControlStationUI(QMainWindow):
super ( ) . __init__ ( )
self . setWindowTitle ( f ' GCS v { self . VERSION } ' )
self . resize ( 1400 , 900 )
self . _closing = False
# 初始化消息隊列(用於線程安全的 GUI 更新)
import queue
@ -168,10 +173,16 @@ class ControlStationUI(QMainWindow):
# 將執行器註冊到 DroneMonitor, 以便動態創建的 CommandLongClient 能被添加
self . monitor . executor = self . executor
# 定时处理ROS事件
self . timer = QTimer ( )
self . timer . timeout . connect ( self . spin_ros )
self . timer . start ( 10 )
# 在背景執行緒處理 ROS2 spin, 避免佔用 Qt 主執行緒時間
self . ros_thread_running = True
self . ros_thread = threading . Thread ( target = self . _ros_spin_thread , daemon = True )
self . ros_thread . start ( )
# 任務規劃移到 worker thread, 避免大量航點計算卡住 Qt 主執行緒
self . planner_pool = ThreadPoolExecutor ( max_workers = 2 )
self . _plan_request_counter = 0
self . _latest_plan_request_by_group = { }
self . planning_finished . connect ( self . _on_plan_done )
# 驅動 asyncio 事件循環的定時器(新增 - 關鍵!)
# 這允許異步任務(如 arm_drone) 能夠執行
@ -179,11 +190,16 @@ class ControlStationUI(QMainWindow):
self . asyncio_timer . timeout . connect ( self . _spin_asyncio )
self . asyncio_timer . start ( 10 ) # 每 10ms 驅動一次 asyncio
# 初始化 panel 和 map 更新( 10Hz)
# 初始化 panel 更新( 10Hz)
self . panel_map_timer = QTimer ( )
self . panel_map_timer . timeout . connect ( self . _update_panel_and_map )
self . panel_map_timer . start ( 100 ) # 10Hz
# Attitude 顯示器獨立高頻更新( 30Hz) , 避免被 panel/table/map 節奏拖住
self . attitude_timer = QTimer ( )
self . attitude_timer . timeout . connect ( self . _update_attitude_only )
self . attitude_timer . start ( 33 )
# 消息隊列處理定時器(來自線程的 GUI 更新)
self . msg_queue_timer = QTimer ( )
self . msg_queue_timer . timeout . connect ( self . _process_message_queue )
@ -191,6 +207,9 @@ class ControlStationUI(QMainWindow):
# 快取消息數據,以便在沒有新消息時使用上一次的值
self . _message_cache = { }
self . _attitude_cache = { }
self . _overview_cache = { }
self . _map_dirty_drones = set ( )
self . message_history = [ ]
self . max_message_history = 500
@ -217,6 +236,17 @@ class ControlStationUI(QMainWindow):
self . drone_headings = { }
# 初始化地圖
self . drone_map = DroneMap ( )
# 地圖更新獨立節流( 10Hz) , 避免 WebEngine / JS map 拖慢 panel 更新
self . map_timer = QTimer ( )
self . map_timer . timeout . connect ( self . _update_map_only )
self . map_timer . start ( 100 )
# Overview table 獨立批次更新( 5Hz) , 避免每筆資料都重繪 Qt table
self . overview_timer = QTimer ( )
self . overview_timer . timeout . connect ( self . _flush_overview_table )
self . overview_timer . start ( 200 )
# 初始化連接列表
self . udp_receivers = [ ]
self . udp_connections = [ ]
@ -391,7 +421,7 @@ class ControlStationUI(QMainWindow):
self . right_vertical_splitter . setChildrenCollapsible ( False )
self . right_vertical_splitter . setStretchFactor ( 0 , 0 )
self . right_vertical_splitter . setStretchFactor ( 1 , 1 )
self . right_vertical_splitter . setSizes ( [ 1 70, 70 0] )
self . right_vertical_splitter . setSizes ( [ 1 80, 64 0] )
right_layout . addWidget ( self . right_vertical_splitter )
# 添加地圖
@ -726,7 +756,7 @@ class ControlStationUI(QMainWindow):
self . message_history = self . message_history [ - self . max_message_history : ]
if hasattr ( self , ' message_history_view ' ) :
self . message_history_view . setPlainText( " \n " . join ( self . message_history ) )
self . message_history_view . appendPlainText( entry )
scrollbar = self . message_history_view . verticalScrollBar ( )
scrollbar . setValue ( scrollbar . maximum ( ) )
@ -800,7 +830,7 @@ class ControlStationUI(QMainWindow):
status_label . setToolTip ( " 已停止 " )
self . statusBar ( ) . showMessage ( f " 已停止 WebSocket 連接: { conn [ ' url ' ] } " , 3000 )
else :
receiver = WebSocketMavlinkReceiver ( conn [ ' url ' ] , self . monitor . signals , conn [ ' name ' ] )
receiver = WebSocketMavlinkReceiver ( conn [ ' url ' ] , self . monitor . signals , conn [ ' name ' ] , self . monitor )
receiver . start ( )
self . monitor . ws_receivers . append ( receiver )
conn [ ' receiver ' ] = receiver
@ -832,7 +862,7 @@ class ControlStationUI(QMainWindow):
status_label . setToolTip ( " 已停止 " )
self . statusBar ( ) . showMessage ( f " 已停止 UDP 連接: { conn [ ' ip ' ] } : { conn [ ' port ' ] } " , 3000 )
else :
receiver = UDPMavlinkReceiver ( conn [ ' ip ' ] , conn [ ' port ' ] , self . monitor . signals , conn [ ' name ' ] )
receiver = UDPMavlinkReceiver ( conn [ ' ip ' ] , conn [ ' port ' ] , self . monitor . signals , conn [ ' name ' ] , self . monitor )
receiver . start ( )
self . udp_receivers . append ( receiver )
conn [ ' receiver ' ] = receiver
@ -1049,7 +1079,7 @@ class ControlStationUI(QMainWindow):
self . group_tab_widget . show ( )
if hasattr ( self , ' right_vertical_splitter ' ) :
total = sum ( self . right_vertical_splitter . sizes ( ) ) or self . height ( )
group_height = getattr ( self , ' _last_group_panel_height ' , 17 0)
group_height = getattr ( self , ' _last_group_panel_height ' , 23 0)
group_height = min ( max ( group_height , 120 ) , max ( 120 , total - 120 ) )
self . right_vertical_splitter . setSizes ( [ group_height , max ( 1 , total - group_height ) ] )
self . toggle_group_btn . setText ( " ▼ " )
@ -1580,6 +1610,8 @@ class ControlStationUI(QMainWindow):
self . _message_cache [ drone_id ] = { }
self . _message_cache [ drone_id ] [ msg_type ] = data
if msg_type == ' attitude ' :
self . _attitude_cache [ drone_id ] = data
# ================================================================================
@ -1733,6 +1765,141 @@ class ControlStationUI(QMainWindow):
""" 取得群組的無人機 ID 列表(排序後) """
return sorted ( group . selected_drone_ids , key = lambda x : ( x . split ( ' _ ' ) [ 0 ] , int ( x . split ( ' _ ' ) [ 1 ] ) ) )
@staticmethod
def _run_plan_formation_mission ( planner_config , drone_gps_positions ,
target_gps , mission_type , params ) :
planner = FormationPlanner ( * * planner_config )
return planner . plan_formation_mission (
drone_gps_positions , target_gps , mission_type , params = params
)
def _submit_mission_plan ( self , group , selected_drones , drone_gps_positions ,
target_gps , mission_type , params , context ) :
self . _plan_request_counter + = 1
request_id = self . _plan_request_counter
self . _latest_plan_request_by_group [ group . group_id ] = request_id
planner_config = {
' spacing ' : self . mission_planner . spacing ,
' base_altitude ' : self . mission_planner . base_altitude ,
' altitude_diff ' : self . mission_planner . altitude_diff ,
}
params = dict ( params )
future = self . planner_pool . submit (
self . _run_plan_formation_mission ,
planner_config ,
list ( drone_gps_positions ) ,
tuple ( target_gps ) ,
mission_type ,
params ,
)
future . add_done_callback (
lambda f : self . _emit_plan_result (
f , request_id , group . group_id , list ( selected_drones ) ,
list ( drone_gps_positions ) , tuple ( target_gps ) , context
)
)
def _emit_plan_result ( self , future , request_id , group_id , selected_drones ,
drone_gps_positions , target_gps , context ) :
if getattr ( self , ' _closing ' , False ) :
return
try :
waypoints_per_drone , center_origin , rendezvous_indices = future . result ( )
payload = {
' ok ' : True ,
' request_id ' : request_id ,
' group_id ' : group_id ,
' selected_drones ' : selected_drones ,
' drone_gps_positions ' : drone_gps_positions ,
' target_gps ' : target_gps ,
' waypoints_per_drone ' : waypoints_per_drone ,
' center_origin ' : center_origin ,
' rendezvous_indices ' : rendezvous_indices ,
' context ' : context ,
}
except Exception as e :
payload = {
' ok ' : False ,
' request_id ' : request_id ,
' group_id ' : group_id ,
' error ' : str ( e ) ,
' traceback ' : traceback . format_exc ( ) ,
' context ' : context ,
}
self . planning_finished . emit ( payload )
def _on_plan_done ( self , payload ) :
if getattr ( self , ' _closing ' , False ) :
return
group_id = payload [ ' group_id ' ]
context = payload . get ( ' context ' , { } )
if self . _latest_plan_request_by_group . get ( group_id ) != payload [ ' request_id ' ] :
return
group = self . mission_groups . get ( group_id )
if not group :
return
if not payload [ ' ok ' ] :
self . statusBar ( ) . showMessage (
f " Group { group_id } : { context . get ( ' failure_label ' , ' 規劃 ' ) } 失敗: { payload [ ' error ' ] } " , 5000
)
_log ( " ERROR " , payload . get ( ' traceback ' , ' ' ) )
return
selected_drones = payload [ ' selected_drones ' ]
drone_gps_positions = payload [ ' drone_gps_positions ' ]
target_gps = payload [ ' target_gps ' ]
waypoints_per_drone = payload [ ' waypoints_per_drone ' ]
center_origin = payload [ ' center_origin ' ]
rendezvous_indices = payload [ ' rendezvous_indices ' ]
expected_mission_type = context . get ( ' expected_mission_type ' )
if set ( group . selected_drone_ids ) != set ( selected_drones ) :
self . statusBar ( ) . showMessage (
f " Group { group_id } : 無人機分配已變更,忽略舊規劃結果 " , 3000
)
return
if expected_mission_type and group . mission_type != expected_mission_type :
self . statusBar ( ) . showMessage (
f " Group { group_id } : 任務模式已變更,忽略舊規劃結果 " , 3000
)
return
group . planned_waypoints = {
' drone_ids ' : selected_drones ,
' waypoints ' : waypoints_per_drone ,
' rendezvous_indices ' : rendezvous_indices ,
}
group . center_origin = center_origin
self . show_planned_waypoints ( group )
center_lat , center_lon , _ = center_origin
target_lat , target_lon = context [ ' draw_target ' ]
self . drone_map . draw_mission_plan_for_group (
group_id , group . color ,
center_lat , center_lon , target_lat , target_lon
)
self . _launch_verification (
context [ ' verification_type ' ] , drone_gps_positions , selected_drones ,
waypoints_per_drone , center_origin ,
target_gps = target_gps if context . get ( ' use_target_gps ' ) else None ,
rect_corners = context . get ( ' rect_corners ' ) ,
route_waypoints = context . get ( ' route_waypoints ' ) ,
)
panel = self . group_panels . get ( group_id )
if panel :
panel . update_status ( )
panel . update_mission_info ( center_lat , center_lon , target_lat , target_lon )
total_wps = sum ( len ( wps ) for wps in waypoints_per_drone )
self . statusBar ( ) . showMessage (
f " Group { group_id } : { context [ ' success_label ' ] } , { len ( selected_drones ) } 台,共 { total_wps } 個航點 " , 5000
)
def handle_map_click ( self , lat , lon ) :
""" 處理地圖點擊事件 — 根據 active group 的任務類型規劃 """
group = self . _get_active_group ( )
@ -1761,46 +1928,22 @@ class ControlStationUI(QMainWindow):
self . statusBar ( ) . showMessage (
f " Group { group . group_id } : 正在規劃 { group . mission_type } ( { len ( selected_drones ) } 台) " , 2000 )
try :
drone_gps_positions = self . _collect_drone_gps ( selected_drones , base_alt )
if drone_gps_positions is None :
return
waypoints_per_drone , center_origin , rendezvous_indices = self . mission_planner . plan_formation_mission (
drone_gps_positions , target_gps , mission_type , params = params
)
group . planned_waypoints = {
' drone_ids ' : selected_drones ,
' waypoints ' : waypoints_per_drone ,
' rendezvous_indices ' : rendezvous_indices ,
}
group . center_origin = center_origin
self . show_planned_waypoints ( group )
center_lat , center_lon , _ = center_origin
self . drone_map . draw_mission_plan_for_group (
group . group_id , group . color ,
center_lat , center_lon , lat , lon
)
self . _launch_verification (
group . mission_type , drone_gps_positions , selected_drones ,
waypoints_per_drone , center_origin , target_gps = target_gps
)
panel = self . group_panels . get ( group . group_id )
if panel :
panel . update_status ( )
panel . update_mission_info ( center_lat , center_lon , lat , lon )
drone_gps_positions = self . _collect_drone_gps ( selected_drones , base_alt )
if drone_gps_positions is None :
return
total_wps = sum ( len ( wps ) for wps in waypoints_per_drone )
self . statusBar ( ) . showMessage (
f " Group { group . group_id } : { group . mission_type } 規劃完成, { len ( selected_drones ) } 台,共 { total_wps } 個航點 " , 5000
)
except Exception as e :
self . statusBar ( ) . showMessage ( f " Group { group . group_id } : 規劃失敗: { str ( e ) } " , 5000 )
traceback . print_exc ( )
context = {
' success_label ' : f " { group . mission_type } 規劃完成 " ,
' failure_label ' : ' 規劃 ' ,
' verification_type ' : group . mission_type ,
' draw_target ' : ( lat , lon ) ,
' use_target_gps ' : True ,
' expected_mission_type ' : group . mission_type ,
}
self . _submit_mission_plan (
group , selected_drones , drone_gps_positions ,
target_gps , mission_type , params , context
)
# ================================================================================
# 任務規劃 — 矩形選取 (Grid Sweep)
@ -1831,51 +1974,27 @@ class ControlStationUI(QMainWindow):
base_alt = params . get ( ' altitude ' , 10.0 )
self . statusBar ( ) . showMessage (
f " Group { group . group_id } : 正在規劃 Grid Sweep ( { len ( selected_drones ) } 台) " , 2000 )
try :
drone_gps_positions = self . _collect_drone_gps ( selected_drones , base_alt )
if drone_gps_positions is None :
return
target_lat = sum ( c [ 0 ] for c in rect_corners ) / 4
target_lon = sum ( c [ 1 ] for c in rect_corners ) / 4
target_gps = ( target_lat , target_lon , base_alt )
params [ ' rect_corners ' ] = rect_corners
waypoints_per_drone , center_origin , rendezvous_indices = self . mission_planner . plan_formation_mission (
drone_gps_positions , target_gps , MissionType . GRID_SWEEP ,
params = params
)
group . planned_waypoints = {
' drone_ids ' : selected_drones ,
' waypoints ' : waypoints_per_drone ,
' rendezvous_indices ' : rendezvous_indices ,
}
group . center_origin = center_origin
self . show_planned_waypoints ( group )
center_lat , center_lon , _ = center_origin
self . drone_map . draw_mission_plan_for_group (
group . group_id , group . color ,
center_lat , center_lon , target_lat , target_lon
)
self . _launch_verification (
' grid_sweep ' , drone_gps_positions , selected_drones ,
waypoints_per_drone , center_origin , rect_corners = rect_corners
)
panel = self . group_panels . get ( group . group_id )
if panel :
panel . update_status ( )
panel . update_mission_info ( center_lat , center_lon , target_lat , target_lon )
drone_gps_positions = self . _collect_drone_gps ( selected_drones , base_alt )
if drone_gps_positions is None :
return
total_wps = sum ( len ( wps ) for wps in waypoints_per_drone )
self . statusBar ( ) . showMessage (
f " Group { group . group_id } : Grid Sweep 規劃完成, { len ( selected_drones ) } 台,共 { total_wps } 個航點 " , 5000
)
except Exception as e :
self . statusBar ( ) . showMessage ( f " Group { group . group_id } : Grid Sweep 規劃失敗: { str ( e ) } " , 5000 )
traceback . print_exc ( )
target_lat = sum ( c [ 0 ] for c in rect_corners ) / 4
target_lon = sum ( c [ 1 ] for c in rect_corners ) / 4
target_gps = ( target_lat , target_lon , base_alt )
params [ ' rect_corners ' ] = rect_corners
context = {
' success_label ' : ' Grid Sweep 規劃完成 ' ,
' failure_label ' : ' Grid Sweep 規劃 ' ,
' verification_type ' : ' grid_sweep ' ,
' draw_target ' : ( target_lat , target_lon ) ,
' rect_corners ' : rect_corners ,
' expected_mission_type ' : group . mission_type ,
}
self . _submit_mission_plan (
group , selected_drones , drone_gps_positions ,
target_gps , MissionType . GRID_SWEEP , params , context
)
# ================================================================================
# 任務規劃 — 路徑確認 (Leader-Follower)
@ -1917,53 +2036,28 @@ class ControlStationUI(QMainWindow):
self . statusBar ( ) . showMessage (
f " Group { group . group_id } : 正在規劃跟隨模式 ( { len ( selected_drones ) } 台) " , 2000 )
try :
drone_gps_positions = self . _collect_drone_gps ( selected_drones , base_alt )
if drone_gps_positions is None :
return
target_lat = sum ( p [ 0 ] for p in route_waypoints ) / len ( route_waypoints )
target_lon = sum ( p [ 1 ] for p in route_waypoints ) / len ( route_waypoints )
target_gps = ( target_lat , target_lon , base_alt )
params [ ' route_waypoints ' ] = route_waypoints
waypoints_per_drone , center_origin , rendezvous_indices = self . mission_planner . plan_formation_mission (
drone_gps_positions , target_gps , MissionType . LEADER_FOLLOWER ,
params = params
)
group . planned_waypoints = {
' drone_ids ' : selected_drones ,
' waypoints ' : waypoints_per_drone ,
' rendezvous_indices ' : rendezvous_indices ,
}
group . center_origin = center_origin
self . show_planned_waypoints ( group )
center_lat , center_lon , _ = center_origin
self . drone_map . draw_mission_plan_for_group (
group . group_id , group . color ,
center_lat , center_lon , target_lat , target_lon
)
self . _launch_verification (
' LEADER_FOLLOWER ' , drone_gps_positions , selected_drones ,
waypoints_per_drone , center_origin ,
target_gps = target_gps , route_waypoints = route_waypoints
)
panel = self . group_panels . get ( group . group_id )
if panel :
panel . update_status ( )
panel . update_mission_info ( center_lat , center_lon , target_lat , target_lon )
drone_gps_positions = self . _collect_drone_gps ( selected_drones , base_alt )
if drone_gps_positions is None :
return
total_wps = sum ( len ( wps ) for wps in waypoints_per_drone )
self . statusBar ( ) . showMessage (
f " Group { group . group_id } : 跟隨模式規劃完成, { len ( selected_drones ) } 台,共 { total_wps } 個航點 " , 5000
)
except Exception as e :
self . statusBar ( ) . showMessage ( f " Group { group . group_id } : 跟隨模式規劃失敗: { str ( e ) } " , 5000 )
traceback . print_exc ( )
target_lat = sum ( p [ 0 ] for p in route_waypoints ) / len ( route_waypoints )
target_lon = sum ( p [ 1 ] for p in route_waypoints ) / len ( route_waypoints )
target_gps = ( target_lat , target_lon , base_alt )
params [ ' route_waypoints ' ] = route_waypoints
context = {
' success_label ' : ' 跟隨模式規劃完成 ' ,
' failure_label ' : ' 跟隨模式規劃 ' ,
' verification_type ' : ' LEADER_FOLLOWER ' ,
' draw_target ' : ( target_lat , target_lon ) ,
' use_target_gps ' : True ,
' route_waypoints ' : route_waypoints ,
' expected_mission_type ' : group . mission_type ,
}
self . _submit_mission_plan (
group , selected_drones , drone_gps_positions ,
target_gps , MissionType . LEADER_FOLLOWER , params , context
)
# ================================================================================
# 任務執行回呼
@ -2059,11 +2153,51 @@ class ControlStationUI(QMainWindow):
if isinstance ( panel , DronePanel ) :
panel . update_field ( field , text , color )
@staticmethod
def _format_gnss_fix_type ( fix_type ) :
fix_labels = {
0 : " No GPS " ,
1 : " No GPS " ,
2 : " 2D Fix " ,
3 : " 3D Fix " ,
4 : " DGPS " ,
5 : " RTK Float " ,
6 : " RTK Fixed " ,
}
try :
return fix_labels . get ( int ( fix_type ) , f " Fix { fix_type } " )
except ( TypeError , ValueError ) :
return " -- "
def update_overview_table ( self , drone_id = None , field = None , value = None ) :
if not hasattr ( self , ' overview_table ' ) or self . overview_table is None : return
self . overview_table . set_drones ( self . drones )
self . overview_table . update_table ( drone_id , field , value )
def queue_overview_update ( self , drone_id , field , value ) :
if not hasattr ( self , ' _overview_cache ' ) :
self . _overview_cache = { }
self . _overview_cache . setdefault ( drone_id , { } ) [ field ] = value
def _flush_overview_table ( self ) :
if not hasattr ( self , ' overview_table ' ) or self . overview_table is None :
return
if not getattr ( self , ' _overview_cache ' , None ) :
return
pending_updates = self . _overview_cache
self . _overview_cache = { }
self . overview_table . set_drones ( self . drones )
self . overview_table . setUpdatesEnabled ( False )
try :
for drone_id , fields in pending_updates . items ( ) :
for field , value in fields . items ( ) :
self . overview_table . update_table ( drone_id , field , value )
finally :
self . overview_table . setUpdatesEnabled ( True )
self . overview_table . viewport ( ) . update ( )
def get_socket_id ( self , drone_id ) :
import re
match = re . match ( r ' s( \ d+)_( \ d+) ' , drone_id )
@ -2100,7 +2234,7 @@ class ControlStationUI(QMainWindow):
self . drone_panel_layout . addWidget ( self . socket_groups [ socket_id ] )
def _update_panel_and_map ( self ) :
""" 30Hz 定時更新 panel 和 map, 批量更新 UI 以避免過度重繪 """
""" 定時更新 panel / table, 批量更新 UI 以避免過度重繪。 """
if not hasattr ( self , ' _message_cache ' ) or not self . _message_cache :
return
@ -2115,20 +2249,17 @@ class ControlStationUI(QMainWindow):
self . _map_update_time = now
self . _map_update_count = 0
# ✅ 步驟 1: 暫停表格的即時重繪
if hasattr ( self , ' overview_table ' ) and self . overview_table :
self . overview_table . setUpdatesEnabled ( False )
try :
start_time = time . time ( )
pending_messages = self . _message_cache
self . _message_cache = { }
# ✅ 步驟 2: 遍歷快取中最新的資料來更新 UI
for drone_id in list ( self . _message_cache . keys ( ) ) :
# ✅ 步驟 2: 只處理本批新資料,避免每個 tick 重跑舊資料造成週期性卡頓
for drone_id , cached_data in pending_messages . items ( ) :
if drone_id not in self . drones :
continue
panel = self . drones [ drone_id ]
cached_data = self . _message_cache [ drone_id ]
# 處理所有快取的消息類型
for msg_type , data in cached_data . items ( ) :
@ -2144,8 +2275,8 @@ class ControlStationUI(QMainWindow):
arm_text , arm_color = " -- " , ' #AAAAAA '
self . update_field ( panel , drone_id , ' mode ' , mode , mode_color )
self . update_field ( panel , drone_id , ' armed ' , arm_text , arm_color )
self . update_overview_tabl e( drone_id , ' mode ' , mode )
self . update_overview_tabl e( drone_id , ' armed ' , arm_text )
self . queue_overview_updat e( drone_id , ' mode ' , mode )
self . queue_overview_updat e( drone_id , ' armed ' , arm_text )
elif msg_type == ' battery ' :
voltage = data . get ( ' voltage ' , 16 )
@ -2158,90 +2289,147 @@ class ControlStationUI(QMainWindow):
self . update_field ( panel , drone_id , ' battery_pct ' , f " { percentage : .0f } % " , voltage_color )
self . update_field ( panel , drone_id , ' battery_vol ' , f " { voltage : .2f } V " )
self . update_field ( panel , drone_id , ' battery_cells ' , f " { cells } S " )
self . update_overview_tabl e( drone_id , ' battery ' , f " { voltage : .2f } V " )
self . queue_overview_updat e( drone_id , ' battery ' , f " { voltage : .2f } V " )
elif msg_type == ' altitude ' :
altitude = data . get ( ' altitude ' , 0 )
text = f " { altitude : .1f } m "
self . update_field ( panel , drone_id , ' altitude ' , text )
self . update_overview_tabl e( drone_id , ' altitude ' , text )
self . queue_overview_updat e( drone_id , ' altitude ' , text )
elif msg_type == ' local_pose ' :
x , y = data . get ( ' x ' , 0 ) , data . get ( ' y ' , 0 )
if not hasattr ( self . monitor , ' drone_local ' ) :
self . monitor . drone_local = { }
self . monitor . drone_local [ drone_id ] = { ' x ' : x , ' y ' : y }
self . update_overview_tabl e( drone_id , ' local ' , f " { x : .1f } , { y : .1f } " )
self . queue_overview_updat e( drone_id , ' local ' , f " { x : .1f } , { y : .1f } " )
elif msg_type == ' loss_rate ' :
text = f " { data . get ( ' loss_rate ' , 0 ) : .1f } % "
self . update_field ( panel , drone_id , ' loss_rate ' , text )
self . update_overview_tabl e( drone_id , ' loss_rate ' , text )
self . queue_overview_updat e( drone_id , ' loss_rate ' , text )
elif msg_type == ' ping ' :
text = f " { data . get ( ' ping ' , 0 ) : .1f } ms "
self . update_field ( panel , drone_id , ' ping ' , text )
self . update_overview_tabl e( drone_id , ' ping ' , text )
self . queue_overview_updat e( drone_id , ' ping ' , text )
elif msg_type == ' velocity ' :
self . update_overview_tabl e( drone_id , ' velocity ' , f " { data [ ' vx ' ] : .1f } , { data [ ' vy ' ] : .1f } " )
self . queue_overview_updat e( drone_id , ' velocity ' , f " { data [ ' vx ' ] : .1f } , { data [ ' vy ' ] : .1f } " )
elif msg_type == ' attitude ' :
roll , pitch , yaw = data . get ( ' roll ' , 0 ) , data . get ( ' pitch ' , 0 ) , data . get ( ' yaw ' , 0 )
self . update_overview_table ( drone_id , ' roll ' , f " { roll : .1f } ° " )
self . update_overview_table ( drone_id , ' pitch ' , f " { pitch : .1f } ° " )
self . update_overview_table ( drone_id , ' yaw ' , f " { yaw : .1f } ° " )
panel . _last_roll = roll
panel . _last_pitch = pitch
if hasattr ( panel , ' update_attitude ' ) :
heading = self . drone_headings . get ( drone_id , yaw )
panel . update_attitude ( heading , roll , pitch )
self . queue_overview_update ( drone_id , ' roll ' , f " { roll : .1f } ° " )
self . queue_overview_update ( drone_id , ' pitch ' , f " { pitch : .1f } ° " )
self . queue_overview_update ( drone_id , ' yaw ' , f " { yaw : .1f } ° " )
elif msg_type == ' gps ' :
gps_data = data
lat , lon = gps_data . get ( ' lat ' , 0 ) , gps_data . get ( ' lon ' , 0 )
self . drone_positions [ drone_id ] = ( lat , lon )
self . _map_dirty_drones . add ( drone_id )
alt = gps_data . get ( ' alt ' , 0 )
if not hasattr ( self . monitor , ' drone_gps ' ) :
self . monitor . drone_gps = { }
self . monitor . drone_gps [ drone_id ] = { ' lat ' : lat , ' lon ' : lon , ' alt ' : alt }
self . update_overview_table ( drone_id , ' latitude ' , f " { lat : .6f } ° " )
self . update_overview_table ( drone_id , ' longitude ' , f " { lon : .6f } ° " )
self . monitor . drone_gps [ drone_id ] = gps_data . copy ( )
self . queue_overview_update ( drone_id , ' latitude ' , f " { lat : .6f } ° " )
self . queue_overview_update ( drone_id , ' longitude ' , f " { lon : .6f } ° " )
fix_type = gps_data . get ( ' fix_type ' )
satellites_visible = gps_data . get ( ' satellites_visible ' )
eph = gps_data . get ( ' eph ' )
epv = gps_data . get ( ' epv ' )
fix_text = self . _format_gnss_fix_type ( fix_type )
self . update_field ( panel , drone_id , ' fix_type_label ' , fix_text )
if isinstance ( eph , ( int , float ) ) and isinstance ( epv , ( int , float ) ) :
error_text = f " { eph : .1f } / { epv : .1f } "
else :
error_text = " -- "
self . update_field ( panel , drone_id , ' gnss_error ' , error_text )
self . queue_overview_update (
drone_id , ' fix_type ' ,
fix_text
)
self . queue_overview_update (
drone_id , ' satellites_visible ' ,
str ( satellites_visible ) if satellites_visible is not None else " -- "
)
self . queue_overview_update (
drone_id , ' eph ' ,
f " { eph : .2f } " if isinstance ( eph , ( int , float ) ) else " -- "
)
self . queue_overview_update (
drone_id , ' epv ' ,
f " { epv : .2f } " if isinstance ( epv , ( int , float ) ) else " -- "
)
elif msg_type == ' hud ' :
hud_data = data
heading = hud_data . get ( ' heading ' , 0 )
self . drone_headings [ drone_id ] = heading
self . _map_dirty_drones . add ( drone_id )
groundspeed = hud_data . get ( ' groundspeed ' , 0 )
airspeed = hud_data . get ( ' airspeed ' , 0 )
throttle = hud_data . get ( ' throttle ' , 0 )
hud_alt = hud_data . get ( ' alt ' , 0 )
climb = hud_data . get ( ' climb ' , 0 )
self . update_overview_table ( drone_id , ' heading ' , f " { heading : .1f } ° " )
self . update_overview_table ( drone_id , ' groundspeed ' , f " { groundspeed : .1f } m/s " if isinstance ( groundspeed , ( int , float ) ) else " -- " )
self . update_overview_table ( drone_id , ' airspeed ' , f " { airspeed : .1f } m/s " if isinstance ( airspeed , ( int , float ) ) else " -- " )
self . update_overview_table ( drone_id , ' throttle ' , f " { throttle : .0f } % " if isinstance ( throttle , ( int , float ) ) else " -- " )
self . update_overview_table ( drone_id , ' hud_alt ' , f " { hud_alt : .1f } m " if isinstance ( hud_alt , ( int , float ) ) else " -- " )
self . update_overview_table ( drone_id , ' climb ' , f " { climb : .1f } m/s " if isinstance ( climb , ( int , float ) ) else " -- " )
self . queue_overview_updat e( drone_id , ' heading ' , f " { heading : .1f } ° " )
self . queue_overview_updat e( drone_id , ' groundspeed ' , f " { groundspeed : .1f } m/s " if isinstance ( groundspeed , ( int , float ) ) else " -- " )
self . queue_overview_updat e( drone_id , ' airspeed ' , f " { airspeed : .1f } m/s " if isinstance ( airspeed , ( int , float ) ) else " -- " )
self . queue_overview_updat e( drone_id , ' throttle ' , f " { throttle : .0f } % " if isinstance ( throttle , ( int , float ) ) else " -- " )
self . queue_overview_updat e( drone_id , ' hud_alt ' , f " { hud_alt : .1f } m " if isinstance ( hud_alt , ( int , float ) ) else " -- " )
self . queue_overview_updat e( drone_id , ' climb ' , f " { climb : .1f } m/s " if isinstance ( climb , ( int , float ) ) else " -- " )
self . update_field ( panel , drone_id , ' heading ' , f " { heading : .1f } ° " )
self . update_field ( panel , drone_id , ' groundspeed ' , f " { groundspeed : .1f } m/s " if isinstance ( groundspeed , ( int , float ) ) else " -- " )
self . update_field ( panel , drone_id , ' speed ' , f " { groundspeed : .1f } m/s " if isinstance ( groundspeed , ( int , float ) ) else " -- " )
if drone_id in self . drone_positions :
lat , lon = self . drone_positions [ drone_id ]
self . drone_map . update_drone_position ( drone_id , lat , lon , heading )
elapsed = ( time . time ( ) - start_time ) * 1000
if elapsed > 33 :
_log ( " WARN " , f " UI 更新耗時 { elapsed : .1f } ms (目標 <33ms) " )
except Exception as e :
_log ( " ERROR " , f " Panel 更新錯誤: { e } " )
traceback . print_exc ( )
finally :
# ✅ 步驟 3: 恢復表格重繪(所有資料已填好,一次性重繪)
if hasattr ( self , ' overview_table ' ) and self . overview_table :
self . overview_table . setUpdatesEnabled ( True )
self . overview_table . viewport ( ) . update ( )
def _update_map_only ( self ) :
""" 獨立降頻更新地圖,避免地圖 JS 呼叫拖住 panel / table。 """
dirty_drones = getattr ( self , ' _map_dirty_drones ' , set ( ) )
if not dirty_drones :
return
pending_drones = list ( dirty_drones )
self . _map_dirty_drones = set ( )
for drone_id in pending_drones :
pos = self . drone_positions . get ( drone_id )
if not pos :
continue
heading = self . drone_headings . get ( drone_id , 0 )
lat , lon = pos
self . drone_map . update_drone_position ( drone_id , lat , lon , heading )
def _update_attitude_only ( self ) :
""" 高頻更新 drone panel 的 attitude 顯示器。 """
if not getattr ( self , ' _attitude_cache ' , None ) :
return
latest_attitudes = self . _attitude_cache
self . _attitude_cache = { }
for drone_id , data in latest_attitudes . items ( ) :
panel = self . drones . get ( drone_id )
if not panel or not hasattr ( panel , ' update_attitude ' ) :
continue
roll = data . get ( ' roll ' , 0 )
pitch = data . get ( ' pitch ' , 0 )
yaw = data . get ( ' yaw ' , 0 )
heading = self . drone_headings . get ( drone_id , yaw )
panel . _last_roll = roll
panel . _last_pitch = pitch
panel . update_attitude ( heading , roll , pitch )
@ -2275,28 +2463,41 @@ class ControlStationUI(QMainWindow):
# 但仍然打印詳細的堆棧跟踪以便調試
traceback . print_exc ( )
def spin_ros ( self ) :
try :
# 仅在 ROS2 正常工作时才尝试 spin
if rclpy . ok ( ) :
def _ros_spin_thread ( self ) :
while self . ros_thread_running and rclpy . ok ( ) :
try :
self . executor . spin_once ( timeout_sec = 0.01 )
for ( drone_id , msg_type ) , data in self . monitor . latest_data . items ( ) :
self . monitor . signals . update_signal . emit ( msg_type , drone_id , data )
latest_items = list ( self . monitor . latest_data . items ( ) )
self . monitor . latest_data . clear ( )
except RuntimeError as e :
# ROS2 context 被破坏或不可用
if " Context " in str ( e ) or " context " in str ( e ) . lower ( ) :
_log ( " WARN " , f " ROS2 context 錯誤(已忽略): { e } " )
else :
_log ( " ERROR " , f " ROS spin error: { e } " )
for ( drone_id , msg_type ) , data in latest_items :
self . monitor . signals . update_signal . emit ( msg_type , drone_id , data )
except RuntimeError as e :
if " Context " in str ( e ) or " context " in str ( e ) . lower ( ) :
_log ( " WARN " , f " ROS2 context 錯誤(已忽略): { e } " )
break
_log ( " ERROR " , f " ROS spin thread error: { e } " )
traceback . print_exc ( )
except Exception as e :
_log ( " ERROR " , f " ROS spin thread error: { e } " )
traceback . print_exc ( )
except Exception as e :
_log ( " ERROR " , f " ROS spin error: { e } " )
traceback . print_exc ( )
def _stop_ros_thread ( self ) :
self . ros_thread_running = False
ros_thread = getattr ( self , ' ros_thread ' , None )
if ros_thread and ros_thread . is_alive ( ) :
ros_thread . join ( timeout = 1.0 )
def closeEvent ( self , event ) :
self . _closing = True
self . _restore_stream_redirector ( )
self . _stop_ros_thread ( )
try :
try :
self . planner_pool . shutdown ( wait = False , cancel_futures = True )
except TypeError :
self . planner_pool . shutdown ( wait = False )
for group in self . mission_groups . values ( ) :
if group . executor :
group . executor . stop ( )