Compare commits

...

3 Commits

@ -1065,6 +1065,39 @@ class DroneMonitor(Node):
traceback.print_exc() traceback.print_exc()
return False return False
async def reboot_drone(self, drone_id):
"""使用 CommandLongClient 執行飛控重啟。"""
try:
parts = drone_id.split('_')
if len(parts) < 2:
_log("ERROR", f"[REBOOT] 無效的 drone_id 格式: {drone_id}")
return False
sysid = int(parts[-1])
_log("INFO", f"[REBOOT] {drone_id} -> 飛控重啟")
client = self.get_or_create_client(drone_id)
if not client:
_log("ERROR", "[REBOOT] CommandLongClient 無法初始化")
return False
result = await client.reboot_autopilot_async(
target_sysid=sysid,
target_compid=0,
timeout_sec=5.0,
)
if result and result.success:
_log("INFO", f"[REBOOT] {drone_id} 重啟命令已送出")
return True
_log("ERROR", f"[REBOOT] 重啟失敗 (message={result.message if result else 'None'})")
return False
except Exception as e:
_log("ERROR", f"[REBOOT] 例外錯誤: {e}")
traceback.print_exc()
return False
def send_setpoint(self, drone_id, x, y, z): def send_setpoint(self, drone_id, x, y, z):
"""Send setpoint position command""" """Send setpoint position command"""
if drone_id not in self.setpoint_pubs: if drone_id not in self.setpoint_pubs:

@ -4,7 +4,8 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout
QWidget, QLabel, QSplitter, QScrollArea, QWidget, QLabel, QSplitter, QScrollArea,
QSizePolicy, QTabWidget, QTableWidget, QTableWidgetItem, QSizePolicy, QTabWidget, QTableWidget, QTableWidgetItem,
QHeaderView, QPushButton, QCheckBox, QLineEdit, QHeaderView, QPushButton, QCheckBox, QLineEdit,
QComboBox, QDialog, QPlainTextEdit, QSlider) QComboBox, QDialog, QPlainTextEdit, QSlider,
QMessageBox)
from PyQt6.QtCore import Qt, QTimer, QObject, pyqtSignal, QEvent, QPropertyAnimation, pyqtProperty, QEasingCurve from PyQt6.QtCore import Qt, QTimer, QObject, pyqtSignal, QEvent, QPropertyAnimation, pyqtProperty, QEasingCurve
from PyQt6.QtGui import QColor, QFont, QPainter, QPen from PyQt6.QtGui import QColor, QFont, QPainter, QPen
import sys import sys
@ -147,7 +148,7 @@ class ToggleSwitch(QWidget):
class ControlStationUI(QMainWindow): class ControlStationUI(QMainWindow):
planning_finished = pyqtSignal(object) planning_finished = pyqtSignal(object)
VERSION = '2.4.1' VERSION = '2.5.0'
FONT_SCALE_MIN = 70 FONT_SCALE_MIN = 70
FONT_SCALE_MAX = 180 FONT_SCALE_MAX = 180
FONT_SCALE_DEFAULT = 100 FONT_SCALE_DEFAULT = 100
@ -1102,6 +1103,7 @@ class ControlStationUI(QMainWindow):
panel.mode_change_requested.connect(self._handle_group_mode_change) panel.mode_change_requested.connect(self._handle_group_mode_change)
panel.arm_requested.connect(self._handle_group_arm) panel.arm_requested.connect(self._handle_group_arm)
panel.takeoff_requested.connect(self._handle_group_takeoff) panel.takeoff_requested.connect(self._handle_group_takeoff)
panel.reboot_requested.connect(self._handle_group_reboot)
panel.box_select_requested.connect(self._handle_box_select) panel.box_select_requested.connect(self._handle_box_select)
panel.select_all_requested.connect(self._handle_select_all_for_group) panel.select_all_requested.connect(self._handle_select_all_for_group)
panel.clear_group_requested.connect(self._handle_clear_group) panel.clear_group_requested.connect(self._handle_clear_group)
@ -1426,6 +1428,39 @@ class ControlStationUI(QMainWindow):
future = self.monitor.takeoff_drone(drone_id, altitude) future = self.monitor.takeoff_drone(drone_id, altitude)
loop.create_task(self.handle_service_response(future, f"起飛 {drone_id} ({altitude}m)")) loop.create_task(self.handle_service_response(future, f"起飛 {drone_id} ({altitude}m)"))
def _handle_group_reboot(self, group_id):
"""重啟群組內所有選中的飛控。"""
group = self.mission_groups.get(group_id)
if not group:
_log("WARN", f"找不到群組: {group_id}")
return
selected = list(group.selected_drone_ids)
if not selected:
self.statusBar().showMessage(f"群組 {group_id} 中沒有無人機", 3000)
return
reply = QMessageBox.warning(
self,
"確認重啟飛控",
f"即將透過 command long 重啟 Group {group_id}{len(selected)} 台飛控:\n"
f"{', '.join(selected)}\n\n"
"這會中斷飛控連線,請確認無人機已在安全狀態。",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
_log("WARN", f"Group {group_id} 飛控重啟已取消")
return
_log("INFO", f"Group {group_id} 批次重啟飛控: {', '.join(selected)}")
self.statusBar().showMessage(f"Group {group_id}: 正在送出重啟命令", 3000)
loop = asyncio.get_event_loop()
for drone_id in selected:
future = self.monitor.reboot_drone(drone_id)
loop.create_task(self.handle_service_response(future, f"重啟飛控 {drone_id}"))
def _handle_box_select(self, group_id): def _handle_box_select(self, group_id):
"""觸發地圖框選 → 框選完成後直接分配到該群組""" """觸發地圖框選 → 框選完成後直接分配到該群組"""
self._pending_box_assign = group_id self._pending_box_assign = group_id

@ -369,6 +369,16 @@ class DroneMap:
var drawStartPoint = null; var drawStartPoint = null;
var mapDragStarted = false; var mapDragStarted = false;
var clickStartPos = null; var clickStartPos = null;
var lastManualMapMoveAt = 0;
var autoCenteringMap = false;
function markManualMapMove() {
if (!autoCenteringMap) {
lastManualMapMoveAt = Date.now();
}
}
map.on('dragstart drag dragend zoomstart zoomend', markManualMapMove);
// 路徑標記變量 (跟隨模式用) // 路徑標記變量 (跟隨模式用)
var routePoints = []; var routePoints = [];
@ -715,13 +725,20 @@ class DroneMap:
if (!markers[id]) return; if (!markers[id]) return;
focusedId = id; focusedId = id;
var latlng = markers[id].getLatLng(); var latlng = markers[id].getLatLng();
autoCenteringMap = true;
map.flyTo(latlng, map.getZoom()); map.flyTo(latlng, map.getZoom());
setTimeout(() => { autoCenteringMap = false; }, 500);
} }
setInterval(() => { setInterval(() => {
if (focusedId && markers[focusedId]) { if (focusedId && markers[focusedId]) {
if (Date.now() - lastManualMapMoveAt < 1000) {
return;
}
var latlng = markers[focusedId].getLatLng(); var latlng = markers[focusedId].getLatLng();
autoCenteringMap = true;
map.panTo(latlng); map.panTo(latlng);
setTimeout(() => { autoCenteringMap = false; }, 300);
} }
}, 1000); }, 1000);

@ -156,6 +156,7 @@ class GroupPanel(QWidget):
mode_change_requested = pyqtSignal(str, str) # group_id, mode mode_change_requested = pyqtSignal(str, str) # group_id, mode
arm_requested = pyqtSignal(str) # group_id arm_requested = pyqtSignal(str) # group_id
takeoff_requested = pyqtSignal(str, float) # group_id, altitude takeoff_requested = pyqtSignal(str, float) # group_id, altitude
reboot_requested = pyqtSignal(str) # group_id
box_select_requested = pyqtSignal(str) # group_id — 框選直接分配 box_select_requested = pyqtSignal(str) # group_id — 框選直接分配
select_all_requested = pyqtSignal(str) # group_id — 全選直接分配 select_all_requested = pyqtSignal(str) # group_id — 全選直接分配
clear_group_requested = pyqtSignal(str) # group_id — 清除分組 clear_group_requested = pyqtSignal(str) # group_id — 清除分組
@ -370,6 +371,23 @@ class GroupPanel(QWidget):
right.addStretch() right.addStretch()
# ============================
# 飛控操作
# ============================
reboot_col = QVBoxLayout()
reboot_col.setSpacing(3)
reboot_title = QLabel("飛控操作")
reboot_title.setStyleSheet(TITLE)
reboot_col.addWidget(reboot_title)
reboot_btn = QPushButton("重啟飛控")
reboot_btn.setStyleSheet(BTN.format(bg='#8A3A3A', fg='#FFF', hover='#A84646'))
reboot_btn.clicked.connect(
lambda: self.reboot_requested.emit(self.group.group_id))
reboot_col.addWidget(reboot_btn)
reboot_col.addStretch()
# ============================ # ============================
# 第四欄:任務參數 # 第四欄:任務參數
# ============================ # ============================
@ -453,7 +471,7 @@ class GroupPanel(QWidget):
# 當任務類型切換時更新參數顯示 # 當任務類型切換時更新參數顯示
self.type_combo.currentTextChanged.connect(self._update_param_visibility) self.type_combo.currentTextChanged.connect(self._update_param_visibility)
# ── 組裝欄:控制指令 > 任務規劃 > 任務參數 > 選取與分組 ── # ── 組裝:控制指令 > 任務規劃 > 任務參數 > 選取與分組 > 飛控操作 ──
# 使用伸展因子 0 讓列根據內容自動調整寬度,而不是均等分配 # 使用伸展因子 0 讓列根據內容自動調整寬度,而不是均等分配
cols.addLayout(left, 0) cols.addLayout(left, 0)
cols.addWidget(self._make_sep()) cols.addWidget(self._make_sep())
@ -462,7 +480,9 @@ class GroupPanel(QWidget):
cols.addLayout(param_col, 0) cols.addLayout(param_col, 0)
cols.addWidget(self._make_sep()) cols.addWidget(self._make_sep())
cols.addLayout(right, 0) cols.addLayout(right, 0)
cols.addStretch() # 填充剩餘空間,使四列置左 cols.addWidget(self._make_sep())
cols.addLayout(reboot_col, 0)
cols.addStretch() # 填充剩餘空間,使欄位置左
layout.addLayout(cols) layout.addLayout(cols)

@ -14,6 +14,7 @@ from fc_interfaces.srv import MavCommandLong
COMMAND_DO_SET_MODE = 176 COMMAND_DO_SET_MODE = 176
COMMAND_NAV_LAND = 21 COMMAND_NAV_LAND = 21
COMMAND_NAV_TAKEOFF = 22 COMMAND_NAV_TAKEOFF = 22
COMMAND_PREFLIGHT_REBOOT_SHUTDOWN = 246
COMMAND_COMPONENT_ARM_DISARM = 400 COMMAND_COMPONENT_ARM_DISARM = 400
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long" DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
@ -191,6 +192,28 @@ class CommandLongClient(Node):
timeout_sec=timeout_sec, timeout_sec=timeout_sec,
) )
def reboot_autopilot(
self,
*,
target_sysid: int,
target_compid: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
return self._send_command_long(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_PREFLIGHT_REBOOT_SHUTDOWN,
confirmation=0,
param1=1.0,
param2=0.0,
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
# ============================================================================ # ============================================================================
# 【新增】非阻塞 async 包裝方法(用於 GUI 的非阻塞調用) # 【新增】非阻塞 async 包裝方法(用於 GUI 的非阻塞調用)
# 這些方法在 ThreadPoolExecutor 中運行同步版本,以避免阻塞事件循環 # 這些方法在 ThreadPoolExecutor 中運行同步版本,以避免阻塞事件循環
@ -353,6 +376,29 @@ class CommandLongClient(Node):
timeout_sec=timeout_sec, timeout_sec=timeout_sec,
) )
async def reboot_autopilot_async(
self,
*,
target_sysid: int,
target_compid: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
"""非阻塞 async 版本的 autopilot reboot."""
return await self._send_command_long_async(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_PREFLIGHT_REBOOT_SHUTDOWN,
confirmation=0,
param1=1.0,
param2=0.0,
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
async def land_async( async def land_async(
self, self,
*, *,

Loading…
Cancel
Save