|
|
|
|
@ -18,11 +18,9 @@ import re
|
|
|
|
|
import threading
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _log(level, message):
|
|
|
|
|
print(f"[{level}] {message}", flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 導入分離的類別
|
|
|
|
|
from communication import DroneMonitor, UDPMavlinkReceiver, WebSocketMavlinkReceiver
|
|
|
|
|
from map_layout import DroneMap
|
|
|
|
|
@ -148,7 +146,7 @@ class ToggleSwitch(QWidget):
|
|
|
|
|
class ControlStationUI(QMainWindow):
|
|
|
|
|
planning_finished = pyqtSignal(object)
|
|
|
|
|
|
|
|
|
|
VERSION = '2.6.0'
|
|
|
|
|
VERSION = '2.7.1'
|
|
|
|
|
FONT_SCALE_MIN = 70
|
|
|
|
|
FONT_SCALE_MAX = 180
|
|
|
|
|
FONT_SCALE_DEFAULT = 100
|
|
|
|
|
@ -211,8 +209,15 @@ class ControlStationUI(QMainWindow):
|
|
|
|
|
self._attitude_cache = {}
|
|
|
|
|
self._overview_cache = {}
|
|
|
|
|
self._map_dirty_drones = set()
|
|
|
|
|
self.message_history = []
|
|
|
|
|
# 「紀錄」分頁分成三個獨立來源;message_history 保留為 GUI 操作紀錄的
|
|
|
|
|
# 相容別名,避免既有程式仍存取舊屬性時失效。
|
|
|
|
|
self.drone_message_history = []
|
|
|
|
|
self.gui_operation_history = []
|
|
|
|
|
self.fcnetwork_history = []
|
|
|
|
|
self.message_history = self.gui_operation_history
|
|
|
|
|
self.max_message_history = 500
|
|
|
|
|
self._sys_diags_warning_signatures = {}
|
|
|
|
|
self._suppressed_status_message = None
|
|
|
|
|
|
|
|
|
|
# 初始化UI
|
|
|
|
|
self.drones = {}
|
|
|
|
|
@ -448,19 +453,19 @@ class ControlStationUI(QMainWindow):
|
|
|
|
|
self.statusBar().messageChanged.connect(self._on_status_bar_message_changed)
|
|
|
|
|
|
|
|
|
|
def _create_message_history_tab(self):
|
|
|
|
|
"""建立左側訊息歷史分頁。"""
|
|
|
|
|
"""建立上、中、下三區的紀錄分頁。"""
|
|
|
|
|
widget = QWidget()
|
|
|
|
|
layout = QVBoxLayout(widget)
|
|
|
|
|
layout.setContentsMargins(10, 10, 10, 10)
|
|
|
|
|
layout.setSpacing(8)
|
|
|
|
|
|
|
|
|
|
header_layout = QHBoxLayout()
|
|
|
|
|
title = QLabel("操作與訊息歷史")
|
|
|
|
|
title = QLabel("訊息與操作紀錄")
|
|
|
|
|
title.setStyleSheet("color: #DDD; font-size: 14px; font-weight: bold;")
|
|
|
|
|
header_layout.addWidget(title)
|
|
|
|
|
header_layout.addStretch()
|
|
|
|
|
|
|
|
|
|
clear_btn = QPushButton("清空")
|
|
|
|
|
clear_btn = QPushButton("全部清空")
|
|
|
|
|
clear_btn.setStyleSheet("""
|
|
|
|
|
QPushButton { background-color: #555; color: white; border: none;
|
|
|
|
|
padding: 5px 10px; border-radius: 4px; font-size: 12px; }
|
|
|
|
|
@ -470,9 +475,7 @@ class ControlStationUI(QMainWindow):
|
|
|
|
|
header_layout.addWidget(clear_btn)
|
|
|
|
|
layout.addLayout(header_layout)
|
|
|
|
|
|
|
|
|
|
self.message_history_view = QPlainTextEdit()
|
|
|
|
|
self.message_history_view.setReadOnly(True)
|
|
|
|
|
self.message_history_view.setStyleSheet("""
|
|
|
|
|
history_style = """
|
|
|
|
|
QPlainTextEdit {
|
|
|
|
|
background-color: #1E1E1E;
|
|
|
|
|
color: #DDD;
|
|
|
|
|
@ -482,9 +485,67 @@ class ControlStationUI(QMainWindow):
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
""")
|
|
|
|
|
self.message_history_view.setPlaceholderText("左下角狀態訊息會顯示在這裡...")
|
|
|
|
|
layout.addWidget(self.message_history_view)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def create_section(section_title, category):
|
|
|
|
|
section = QWidget()
|
|
|
|
|
section_layout = QVBoxLayout(section)
|
|
|
|
|
section_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
section_layout.setSpacing(4)
|
|
|
|
|
|
|
|
|
|
section_header = QHBoxLayout()
|
|
|
|
|
section_label = QLabel(section_title)
|
|
|
|
|
section_label.setStyleSheet(
|
|
|
|
|
"color: #CCC; font-size: 12px; font-weight: bold;")
|
|
|
|
|
section_header.addWidget(section_label)
|
|
|
|
|
section_header.addStretch()
|
|
|
|
|
|
|
|
|
|
section_clear_btn = QPushButton("清空")
|
|
|
|
|
section_clear_btn.setStyleSheet("""
|
|
|
|
|
QPushButton { background-color: #444; color: #DDD; border: none;
|
|
|
|
|
padding: 3px 8px; border-radius: 3px; font-size: 11px; }
|
|
|
|
|
QPushButton:hover { background-color: #555; }
|
|
|
|
|
""")
|
|
|
|
|
section_clear_btn.clicked.connect(
|
|
|
|
|
lambda _checked=False, name=category:
|
|
|
|
|
self._clear_message_history(name)
|
|
|
|
|
)
|
|
|
|
|
section_header.addWidget(section_clear_btn)
|
|
|
|
|
section_layout.addLayout(section_header)
|
|
|
|
|
|
|
|
|
|
view = QPlainTextEdit()
|
|
|
|
|
view.setReadOnly(True)
|
|
|
|
|
view.setStyleSheet(history_style)
|
|
|
|
|
view.document().setMaximumBlockCount(self.max_message_history)
|
|
|
|
|
section_layout.addWidget(view)
|
|
|
|
|
return section, view
|
|
|
|
|
|
|
|
|
|
history_splitter = QSplitter(Qt.Orientation.Vertical)
|
|
|
|
|
history_splitter.setChildrenCollapsible(False)
|
|
|
|
|
|
|
|
|
|
gui_section, self.gui_operation_history_view = create_section(
|
|
|
|
|
"操作紀錄", "gui")
|
|
|
|
|
drone_section, self.drone_message_history_view = create_section(
|
|
|
|
|
"無人機狀態", "drone")
|
|
|
|
|
fcnetwork_section, self.fcnetwork_history_view = create_section(
|
|
|
|
|
"FC Network", "fcnetwork")
|
|
|
|
|
|
|
|
|
|
history_splitter.addWidget(gui_section)
|
|
|
|
|
history_splitter.addWidget(drone_section)
|
|
|
|
|
history_splitter.addWidget(fcnetwork_section)
|
|
|
|
|
history_splitter.setStretchFactor(0, 1)
|
|
|
|
|
history_splitter.setStretchFactor(1, 1)
|
|
|
|
|
history_splitter.setStretchFactor(2, 1)
|
|
|
|
|
history_splitter.setSizes([220, 220, 220])
|
|
|
|
|
layout.addWidget(history_splitter)
|
|
|
|
|
|
|
|
|
|
self._history_views = {
|
|
|
|
|
'drone': self.drone_message_history_view,
|
|
|
|
|
'gui': self.gui_operation_history_view,
|
|
|
|
|
'fcnetwork': self.fcnetwork_history_view,
|
|
|
|
|
}
|
|
|
|
|
# 舊名稱指向 GUI 操作紀錄,保留向後相容性。
|
|
|
|
|
self.message_history_view = self.gui_operation_history_view
|
|
|
|
|
|
|
|
|
|
return widget
|
|
|
|
|
|
|
|
|
|
@ -741,27 +802,114 @@ class ControlStationUI(QMainWindow):
|
|
|
|
|
|
|
|
|
|
return super().eventFilter(obj, event)
|
|
|
|
|
|
|
|
|
|
def _clear_message_history(self):
|
|
|
|
|
"""清空訊息歷史。"""
|
|
|
|
|
self.message_history.clear()
|
|
|
|
|
if hasattr(self, 'message_history_view'):
|
|
|
|
|
self.message_history_view.clear()
|
|
|
|
|
def _history_list(self, category):
|
|
|
|
|
return {
|
|
|
|
|
'drone': self.drone_message_history,
|
|
|
|
|
'gui': self.gui_operation_history,
|
|
|
|
|
'fcnetwork': self.fcnetwork_history,
|
|
|
|
|
}.get(category)
|
|
|
|
|
|
|
|
|
|
def _on_status_bar_message_changed(self, message):
|
|
|
|
|
"""同步狀態列訊息到歷史紀錄。"""
|
|
|
|
|
def _append_history(self, category, message):
|
|
|
|
|
"""加入指定來源的紀錄並讓畫面保持在最新一筆。"""
|
|
|
|
|
if not message:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
timestamp = time.strftime("%H:%M:%S")
|
|
|
|
|
entry = f"[{timestamp}] {message}"
|
|
|
|
|
self.message_history.append(entry)
|
|
|
|
|
if len(self.message_history) > self.max_message_history:
|
|
|
|
|
self.message_history = self.message_history[-self.max_message_history:]
|
|
|
|
|
history = self._history_list(category)
|
|
|
|
|
if history is None:
|
|
|
|
|
return
|
|
|
|
|
history.append(entry)
|
|
|
|
|
if len(history) > self.max_message_history:
|
|
|
|
|
del history[:-self.max_message_history]
|
|
|
|
|
|
|
|
|
|
if hasattr(self, 'message_history_view'):
|
|
|
|
|
self.message_history_view.appendPlainText(entry)
|
|
|
|
|
scrollbar = self.message_history_view.verticalScrollBar()
|
|
|
|
|
view = getattr(self, '_history_views', {}).get(category)
|
|
|
|
|
if view is not None:
|
|
|
|
|
view.appendPlainText(entry)
|
|
|
|
|
scrollbar = view.verticalScrollBar()
|
|
|
|
|
scrollbar.setValue(scrollbar.maximum())
|
|
|
|
|
|
|
|
|
|
def _clear_message_history(self, category=None):
|
|
|
|
|
"""清空單一來源;未指定來源時清空全部。"""
|
|
|
|
|
categories = (category,) if category else ('drone', 'gui', 'fcnetwork')
|
|
|
|
|
for name in categories:
|
|
|
|
|
history = self._history_list(name)
|
|
|
|
|
if history is not None:
|
|
|
|
|
history.clear()
|
|
|
|
|
view = getattr(self, '_history_views', {}).get(name)
|
|
|
|
|
if view is not None:
|
|
|
|
|
view.clear()
|
|
|
|
|
|
|
|
|
|
def _record_drone_status(self, sys_id, data):
|
|
|
|
|
"""顯示由 status_text topic 收到的飛行檢查與報錯。"""
|
|
|
|
|
severity_labels = {
|
|
|
|
|
0: 'EMERGENCY', 1: 'ALERT', 2: 'CRITICAL', 3: 'ERROR',
|
|
|
|
|
4: 'WARNING', 5: 'NOTICE', 6: 'INFO', 7: 'DEBUG'
|
|
|
|
|
}
|
|
|
|
|
severity = data.get('severity', -1)
|
|
|
|
|
label = severity_labels.get(severity, 'STATUS')
|
|
|
|
|
self._append_history(
|
|
|
|
|
'drone', f"/fc_network/vehicle/{sys_id}/status_text "
|
|
|
|
|
f"[{label}] {data.get('text', '')}")
|
|
|
|
|
|
|
|
|
|
def _record_sys_diags_warning(self, sys_id, data):
|
|
|
|
|
"""sys_diags 只在診斷值異常或內容改變時顯示警告。"""
|
|
|
|
|
installed = int(data.get('sensors_install_mask', 0))
|
|
|
|
|
enabled = int(data.get('sensors_enabled_mask', 0))
|
|
|
|
|
healthy = int(data.get('sensors_health_mask', 0))
|
|
|
|
|
unhealthy_mask = installed & enabled & ~healthy
|
|
|
|
|
load = float(data.get('mcu_load_percent', 0.0))
|
|
|
|
|
bus_rate = float(data.get('bus_error_rate_percent', 0.0))
|
|
|
|
|
error_counts = tuple(int(data.get(f'errors_count{i}', 0)) for i in range(1, 5))
|
|
|
|
|
|
|
|
|
|
warnings = []
|
|
|
|
|
if unhealthy_mask:
|
|
|
|
|
warnings.append(f"感測器異常 mask=0x{unhealthy_mask:08X}")
|
|
|
|
|
if load >= 90.0:
|
|
|
|
|
warnings.append(f"MCU 負載過高 {load:.1f}%")
|
|
|
|
|
if bus_rate > 0.0 or int(data.get('bus_error_count', 0)) > 0:
|
|
|
|
|
warnings.append(
|
|
|
|
|
f"匯流排錯誤率 {bus_rate:.1f}% / "
|
|
|
|
|
f"count={int(data.get('bus_error_count', 0))}")
|
|
|
|
|
if any(error_counts):
|
|
|
|
|
warnings.append(f"錯誤計數={error_counts}")
|
|
|
|
|
|
|
|
|
|
signature = tuple(warnings)
|
|
|
|
|
if not signature:
|
|
|
|
|
self._sys_diags_warning_signatures.pop(sys_id, None)
|
|
|
|
|
return
|
|
|
|
|
if self._sys_diags_warning_signatures.get(sys_id) == signature:
|
|
|
|
|
return
|
|
|
|
|
self._sys_diags_warning_signatures[sys_id] = signature
|
|
|
|
|
self._append_history(
|
|
|
|
|
'drone', f"/fc_network/vehicle/{sys_id}/sys_diags [WARNING] "
|
|
|
|
|
+ ";".join(warnings))
|
|
|
|
|
|
|
|
|
|
def _record_fcnetwork_log(self, logger_name, data):
|
|
|
|
|
"""顯示 /fc_network/logs 的結構化 FC Network 紀錄。"""
|
|
|
|
|
level_labels = {
|
|
|
|
|
10: 'DEBUG', 20: 'INFO', 30: 'WARN', 40: 'ERROR', 50: 'FATAL'
|
|
|
|
|
}
|
|
|
|
|
level = level_labels.get(data.get('level'), str(data.get('level', '')))
|
|
|
|
|
context = []
|
|
|
|
|
if data.get('event_code'):
|
|
|
|
|
context.append(data['event_code'])
|
|
|
|
|
if data.get('sysid', -1) >= 0:
|
|
|
|
|
context.append(f"sys{data['sysid']}")
|
|
|
|
|
context_text = f" [{' / '.join(context)}]" if context else ''
|
|
|
|
|
self._append_history(
|
|
|
|
|
'fcnetwork', f"/fc_network/logs [{level}] [{logger_name}]"
|
|
|
|
|
f"{context_text} {data.get('message', '')}")
|
|
|
|
|
|
|
|
|
|
def _on_status_bar_message_changed(self, message):
|
|
|
|
|
"""同步 GUI 狀態列訊息到操作紀錄。"""
|
|
|
|
|
if not message:
|
|
|
|
|
return
|
|
|
|
|
if message == self._suppressed_status_message:
|
|
|
|
|
self._suppressed_status_message = None
|
|
|
|
|
return
|
|
|
|
|
self._append_history('gui', message)
|
|
|
|
|
|
|
|
|
|
def _setup_stream_redirector(self):
|
|
|
|
|
"""將 stdout/stderr 同步到左下角狀態列與訊息紀錄。"""
|
|
|
|
|
self._original_stdout = sys.stdout
|
|
|
|
|
@ -783,10 +931,14 @@ class ControlStationUI(QMainWindow):
|
|
|
|
|
sys.stderr = self._original_stderr
|
|
|
|
|
|
|
|
|
|
def show_in_bottom_left(self, text):
|
|
|
|
|
"""將重導向的輸出顯示在左下角狀態列。"""
|
|
|
|
|
"""背景輸出只短暫顯示在狀態列,不混入三類紀錄。"""
|
|
|
|
|
if not text:
|
|
|
|
|
return
|
|
|
|
|
# 背景輸出仍沿用既有狀態列提示,但避免又被當成 GUI 操作紀錄。
|
|
|
|
|
self._suppressed_status_message = text
|
|
|
|
|
self.statusBar().showMessage(text, 5000)
|
|
|
|
|
if self._suppressed_status_message == text:
|
|
|
|
|
self._suppressed_status_message = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================================
|
|
|
|
|
@ -1323,6 +1475,7 @@ class ControlStationUI(QMainWindow):
|
|
|
|
|
arrival_radius=exec_params.get('arrival_radius', 4.0),
|
|
|
|
|
hover_stable_sec=exec_params.get('hover_stable_sec', 2.0),
|
|
|
|
|
tick_rate_hz=2.0,
|
|
|
|
|
resend_interval_sec=exec_params.get('resend_interval_sec', 2.5),
|
|
|
|
|
)
|
|
|
|
|
executor.drone_waypoint_reached.connect(self.on_drone_waypoint_reached)
|
|
|
|
|
executor.task_status_changed.connect(self._on_task_status_changed)
|
|
|
|
|
@ -1660,6 +1813,15 @@ class ControlStationUI(QMainWindow):
|
|
|
|
|
|
|
|
|
|
def update_ui(self, msg_type, drone_id, data):
|
|
|
|
|
"""只做數據快取,不在這裡更新 UI"""
|
|
|
|
|
if msg_type == 'status_text':
|
|
|
|
|
self._record_drone_status(drone_id, data)
|
|
|
|
|
return
|
|
|
|
|
if msg_type == 'sys_diags':
|
|
|
|
|
self._record_sys_diags_warning(drone_id, data)
|
|
|
|
|
return
|
|
|
|
|
if msg_type == 'fc_network_log':
|
|
|
|
|
self._record_fcnetwork_log(drone_id, data)
|
|
|
|
|
return
|
|
|
|
|
if msg_type == 'connection_type':
|
|
|
|
|
conn_type = data.get('type', 'Unknown')
|
|
|
|
|
parts = drone_id.split('_')
|
|
|
|
|
@ -2133,14 +2295,40 @@ class ControlStationUI(QMainWindow):
|
|
|
|
|
# 任務執行回呼
|
|
|
|
|
# ================================================================================
|
|
|
|
|
|
|
|
|
|
# 任務狀態列的通訊狀態短標籤(對應 MissionExecutor 的 TaskStatus.value)
|
|
|
|
|
_MISSION_COMM_LABELS = {
|
|
|
|
|
"normal": "送出OK",
|
|
|
|
|
"retrying": "送出未驗證",
|
|
|
|
|
"waiting_at_barrier": "等待同伴",
|
|
|
|
|
"fallback_loiter": "LOITER",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _set_overview_mission(self, drone_id, wp=None, comm=None):
|
|
|
|
|
"""組合 WP 進度 + 通訊狀態,寫進總覽表『任務狀態』列(每台一格,實飛一眼看全部)。"""
|
|
|
|
|
cache = getattr(self, '_mission_ui', None)
|
|
|
|
|
if cache is None:
|
|
|
|
|
cache = self._mission_ui = {}
|
|
|
|
|
slot = cache.setdefault(drone_id, {'wp': '', 'comm': ''})
|
|
|
|
|
if wp is not None:
|
|
|
|
|
slot['wp'] = wp
|
|
|
|
|
if comm is not None:
|
|
|
|
|
slot['comm'] = comm
|
|
|
|
|
text = ' · '.join(p for p in (slot['wp'], slot['comm']) if p) or '--'
|
|
|
|
|
self.update_overview_table(drone_id, 'mission', text)
|
|
|
|
|
|
|
|
|
|
def on_drone_waypoint_reached(self, drone_id, wp_index, total):
|
|
|
|
|
if wp_index >= total:
|
|
|
|
|
self.statusBar().showMessage(f"{drone_id} 已完成所有航點", 3000)
|
|
|
|
|
self._set_overview_mission(drone_id, wp=f"完成 {total}/{total}")
|
|
|
|
|
else:
|
|
|
|
|
self.statusBar().showMessage(f"{drone_id} 到達 WP {wp_index}/{total}", 2000)
|
|
|
|
|
self._set_overview_mission(drone_id, wp=f"WP {wp_index}/{total}")
|
|
|
|
|
|
|
|
|
|
def _on_task_status_changed(self, drone_id, status, message):
|
|
|
|
|
"""MissionExecutor.task_status_changed slot:把 goto 失敗/重試/fallback/barrier 丟到 status bar"""
|
|
|
|
|
"""MissionExecutor.task_status_changed slot:狀態列提示 + 更新總覽表『任務狀態』列"""
|
|
|
|
|
comm = self._MISSION_COMM_LABELS.get(status)
|
|
|
|
|
if comm is not None:
|
|
|
|
|
self._set_overview_mission(drone_id, comm=comm)
|
|
|
|
|
if status == "retrying":
|
|
|
|
|
self.statusBar().showMessage(f"{drone_id} {message}", 4000)
|
|
|
|
|
elif status == "fallback_loiter":
|
|
|
|
|
|