#!/usr/bin/env python3 """ 任務群組模組 管理多任務群組的資料結構與無人機分配對話框 """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QComboBox, QDialog, QCheckBox, QScrollArea, QFrame, QLineEdit ) from PyQt6.QtCore import Qt, pyqtSignal from mission_executor import MissionExecutor, MissionState # 群組顏色(循環使用) GROUP_COLORS = [ '#4A9EFF', # 藍 '#FF8C42', # 橘 '#56C87A', # 綠 '#E85D75', # 紅 '#B07CED', # 紫 '#F5C542', # 黃 '#42C9C9', # 青 '#FF6B9D', # 粉 ] class MissionGroup: """單一任務群組的資料""" def __init__(self, group_id, color): self.group_id = group_id # 'A', 'B', 'C', ... self.color = color # 顏色 hex self.drone_ids = set() # 已分配的無人機 ID self.mission_type = 'M_FORMATION' # 預設任務類型 self.planned_waypoints = None # 規劃結果 dict self.executor = None # MissionExecutor 實例(延遲建立) self.center_origin = None # 規劃原點 self.leader_drone_id = None # LEADER_FOLLOWER 專用:指定的領隊無人機 ID @property def state(self): if self.executor is None: return MissionState.IDLE return self.executor.state @property def display_name(self): return f"Group {self.group_id}" class DroneAssignDialog(QDialog): """無人機分配對話框""" def __init__(self, parent, all_drone_ids, current_assigned, other_assigned): """ Args: parent: 父 widget all_drone_ids: 所有可用無人機 ID 列表 current_assigned: 當前群組已分配的無人機 set other_assigned: 其他群組已佔用的無人機 dict {drone_id: group_id} """ super().__init__(parent) self.setWindowTitle("分配無人機") self.setMinimumWidth(280) self.setStyleSheet(""" QDialog { background-color: #2B2B2B; } QLabel { color: #DDD; } QCheckBox { color: #DDD; spacing: 8px; padding: 4px; } QCheckBox::indicator { width: 16px; height: 16px; } QCheckBox:disabled { color: #666; } """) layout = QVBoxLayout(self) title = QLabel("選擇要分配到此群組的無人機:") title.setStyleSheet("font-size: 13px; font-weight: bold; padding-bottom: 6px;") layout.addWidget(title) # 滾動區域 scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setMaximumHeight(300) scroll_widget = QWidget() scroll_layout = QVBoxLayout(scroll_widget) scroll_layout.setSpacing(2) self.checkboxes = {} sorted_ids = sorted(all_drone_ids, key=lambda x: (x.split('_')[0], int(x.split('_')[1]))) for drone_id in sorted_ids: cb = QCheckBox(drone_id) if drone_id in current_assigned: cb.setChecked(True) elif drone_id in other_assigned: cb.setEnabled(False) cb.setToolTip(f"已分配到 Group {other_assigned[drone_id]}") cb.setText(f"{drone_id} (Group {other_assigned[drone_id]})") self.checkboxes[drone_id] = cb scroll_layout.addWidget(cb) scroll_layout.addStretch() scroll.setWidget(scroll_widget) layout.addWidget(scroll) # 按鈕 btn_layout = QHBoxLayout() ok_btn = QPushButton("確定") ok_btn.setStyleSheet(""" QPushButton { background-color: #4A9EFF; color: white; border: none; padding: 8px 20px; border-radius: 4px; font-weight: bold; } QPushButton:hover { background-color: #3A8EEF; } """) ok_btn.clicked.connect(self.accept) cancel_btn = QPushButton("取消") cancel_btn.setStyleSheet(""" QPushButton { background-color: #555; color: #DDD; border: none; padding: 8px 20px; border-radius: 4px; } QPushButton:hover { background-color: #666; } """) cancel_btn.clicked.connect(self.reject) btn_layout.addStretch() btn_layout.addWidget(cancel_btn) btn_layout.addWidget(ok_btn) layout.addLayout(btn_layout) def get_selected(self): """回傳勾選的無人機 ID set""" return {did for did, cb in self.checkboxes.items() if cb.isChecked() and cb.isEnabled()} class GroupPanel(QWidget): """單一群組的 UI 面板(嵌入到 tab 中)— 三欄式佈局""" # 信號 assign_drones_requested = pyqtSignal(str) # group_id mission_type_changed = pyqtSignal(str, str) # group_id, mission_type start_requested = pyqtSignal(str) # group_id pause_requested = pyqtSignal(str) # group_id stop_requested = pyqtSignal(str) # group_id mode_change_requested = pyqtSignal(str, str) # group_id, mode arm_requested = pyqtSignal(str) # group_id takeoff_requested = pyqtSignal(str, float) # group_id, altitude box_select_requested = pyqtSignal(str) # group_id — 框選直接分配 select_all_requested = pyqtSignal(str) # group_id — 全選直接分配 clear_group_requested = pyqtSignal(str) # group_id — 清除分組 add_group_requested = pyqtSignal() # 新增群組 delete_group_requested = pyqtSignal(str) # group_id — 刪除群組 BUTTON_STYLE = """ QPushButton {{ background-color: {bg}; color: {fg}; border: none; padding: 5px 8px; border-radius: 4px; font-size: 11px; }} QPushButton:hover {{ background-color: {hover}; }} QPushButton:disabled {{ background-color: #444; color: #666; }} """ def __init__(self, group: MissionGroup, parent=None): super().__init__(parent) self.group = group self._is_all_selected = False # 追蹤全選狀態 self.all_btn_ref = None # 保存全選按鈕的參考 self._build_ui() def _make_sep(self): """建立垂直分隔線""" sep = QFrame() sep.setFrameShape(QFrame.Shape.VLine) sep.setStyleSheet("color: #444;") return sep def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(6, 4, 6, 4) layout.setSpacing(0) COMBO = ("QComboBox { background-color: #333; color: #DDD; " "border-radius: 3px; padding: 2px 6px; font-size: 11px; }") BTN = self.BUTTON_STYLE LBL = "color: #AAA; font-size: 11px;" TITLE = "color: #DDD; font-size: 11px; font-weight: bold; padding-bottom: 2px;" # ── 三欄主佈局 ── cols = QHBoxLayout() cols.setSpacing(6) # ============================ # 左欄:控制指令 # ============================ left = QVBoxLayout() left.setSpacing(3) left_title = QLabel("控制指令") left_title.setStyleSheet(TITLE) left.addWidget(left_title) # 模式切換 mode_row = QHBoxLayout() mode_row.setSpacing(3) self.mode_combo = QComboBox() self.mode_combo.addItems([ "GUIDED", "AUTO", "LAND", "LOITER", "STABILIZE", "ALT_HOLD", "RTL", "POSHOLD", "SMART_RTL" ]) self.mode_combo.setStyleSheet(COMBO) mode_btn = QPushButton("切換") mode_btn.setStyleSheet(BTN.format(bg='#555', fg='#DDD', hover='#666')) mode_btn.clicked.connect( lambda: self.mode_change_requested.emit( self.group.group_id, self.mode_combo.currentText())) mode_row.addWidget(self.mode_combo, 1) mode_row.addWidget(mode_btn) left.addLayout(mode_row) # 解鎖 arm_btn = QPushButton("解鎖") arm_btn.setStyleSheet(BTN.format(bg='#555', fg='#DDD', hover='#666')) arm_btn.clicked.connect( lambda: self.arm_requested.emit(self.group.group_id)) left.addWidget(arm_btn) # 起飛 takeoff_row = QHBoxLayout() takeoff_row.setSpacing(3) self.alt_input = QComboBox() self.alt_input.setEditable(True) self.alt_input.addItems(["5", "10", "15", "20"]) self.alt_input.setCurrentText("10") self.alt_input.setStyleSheet(COMBO) alt_lbl = QLabel("m") alt_lbl.setStyleSheet(LBL) takeoff_btn = QPushButton("起飛") takeoff_btn.setStyleSheet(BTN.format(bg='#555', fg='#DDD', hover='#666')) takeoff_btn.clicked.connect(self._on_takeoff) takeoff_row.addWidget(self.alt_input, 1) takeoff_row.addWidget(alt_lbl) takeoff_row.addWidget(takeoff_btn) left.addLayout(takeoff_row) left.addStretch() # ============================ # 中欄:任務規劃(左右分割) # ============================ mid = QVBoxLayout() mid.setSpacing(2) mid_title = QLabel("任務規劃") mid_title.setStyleSheet(TITLE) mid.addWidget(mid_title) mid_body = QHBoxLayout() mid_body.setSpacing(4) # 左側:類型 + 狀態 + 座標 mid_left = QVBoxLayout() mid_left.setSpacing(2) self.type_combo = QComboBox() self.type_combo.addItems([ "M_FORMATION", "CIRCLE_FORMATION", "LEADER_FOLLOWER", "GRID_SWEEP" ]) self.type_combo.setStyleSheet(COMBO) self.type_combo.currentTextChanged.connect( lambda t: self.mission_type_changed.emit(self.group.group_id, t)) mid_left.addWidget(self.type_combo) self.status_label = QLabel("○ 未規劃") self.status_label.setStyleSheet("color: #888; font-size: 11px;") mid_left.addWidget(self.status_label) self.center_label = QLabel("中心: --") self.center_label.setStyleSheet("color: #AAA; font-size: 11px;") mid_left.addWidget(self.center_label) self.target_label = QLabel("目標: --") self.target_label.setStyleSheet("color: #AAA; font-size: 11px;") mid_left.addWidget(self.target_label) mid_left.addStretch() # 右側:執行 / 暫停 / 停止(垂直排列) mid_right = QVBoxLayout() mid_right.setSpacing(3) self.start_btn = QPushButton("▶ 執行") self.start_btn.setStyleSheet(BTN.format(bg='#2E7D32', fg='white', hover='#388E3C')) self.start_btn.clicked.connect( lambda: self.start_requested.emit(self.group.group_id)) self.pause_btn = QPushButton("⏸ 暫停") self.pause_btn.setStyleSheet(BTN.format(bg='#F57F17', fg='white', hover='#F9A825')) self.pause_btn.clicked.connect( lambda: self.pause_requested.emit(self.group.group_id)) self.stop_btn = QPushButton("■ 停止") self.stop_btn.setStyleSheet(BTN.format(bg='#C62828', fg='white', hover='#D32F2F')) self.stop_btn.clicked.connect( lambda: self.stop_requested.emit(self.group.group_id)) mid_right.addWidget(self.start_btn) mid_right.addWidget(self.pause_btn) mid_right.addWidget(self.stop_btn) mid_right.addStretch() mid_body.addLayout(mid_left, 1) mid_body.addLayout(mid_right) mid.addLayout(mid_body) # ============================ # 選取與分組(3x2 按鈕) # ============================ right = QVBoxLayout() right.setSpacing(3) right_title = QLabel("選取與分組") right_title.setStyleSheet(TITLE) right.addWidget(right_title) self.drone_list_label = QLabel("尚未分配") self.drone_list_label.setStyleSheet("color: #888; font-size: 11px;") self.drone_list_label.setWordWrap(True) right.addWidget(self.drone_list_label) # 3x2 按鈕網格:第一行 框選 全選 新增群組 grid_r1 = QHBoxLayout() grid_r1.setSpacing(3) box_btn = QPushButton("框選") box_btn.setStyleSheet(BTN.format(bg='#64B5F6', fg='white', hover='#42A5F5')) box_btn.clicked.connect( lambda: self.box_select_requested.emit(self.group.group_id)) all_btn = QPushButton("全選/取消") all_btn.setStyleSheet(BTN.format(bg='#64B5F6', fg='white', hover='#42A5F5')) all_btn.clicked.connect(self._on_all_select_clicked) self.all_btn_ref = all_btn # 保存按鈕參考(備用) add_group_btn = QPushButton("+ 新增群組") add_group_btn.setStyleSheet(BTN.format(bg='#4A9EFF', fg='white', hover='#3A8EEF')) add_group_btn.clicked.connect(lambda: self.add_group_requested.emit()) grid_r1.addWidget(box_btn) grid_r1.addWidget(all_btn) grid_r1.addWidget(add_group_btn) right.addLayout(grid_r1) # 第二行 編輯分配 清除分組 刪除群組 grid_r2 = QHBoxLayout() grid_r2.setSpacing(3) assign_btn = QPushButton("編輯分配") assign_btn.setStyleSheet(BTN.format(bg='#555', fg='#DDD', hover='#666')) assign_btn.clicked.connect( lambda: self.assign_drones_requested.emit(self.group.group_id)) clear_btn = QPushButton("清除分組") clear_btn.setStyleSheet(BTN.format(bg='#777', fg='white', hover='#888')) clear_btn.clicked.connect( lambda: self.clear_group_requested.emit(self.group.group_id)) self.delete_group_btn = QPushButton("刪除群組") self.delete_group_btn.setStyleSheet(BTN.format(bg='#EF5350', fg='white', hover='#E53935')) self.delete_group_btn.clicked.connect( lambda: self.delete_group_requested.emit(self.group.group_id)) grid_r2.addWidget(assign_btn) grid_r2.addWidget(clear_btn) grid_r2.addWidget(self.delete_group_btn) right.addLayout(grid_r2) right.addStretch() # ============================ # 第四欄:任務參數 # ============================ param_col = QVBoxLayout() param_col.setSpacing(2) param_title = QLabel("任務參數") param_title.setStyleSheet(TITLE) param_col.addWidget(param_title) INPUT = ("QLineEdit { background-color: #333; color: #DDD; " "border: 1px solid #555; border-radius: 3px; " "padding: 1px 4px; font-size: 11px; }") # 每種任務類型的參數定義: (key, label, default_value) self._param_defs = { 'M_FORMATION': [ ('spacing', '間距 (m)', '5.0'), ('base_altitude', '基準高度 (m)', '10.0'), ('altitude_diff', '高低差 (m)', '2.0'), ], 'CIRCLE_FORMATION': [ ('radius', '半徑 (m)', '10.0'), ('altitude', '高度 (m)', '10.0'), ('start_angle', '起始角 (°)', '0'), ], 'LEADER_FOLLOWER': [ ('lateral_offset', '橫向偏移 (m)', '3.0'), ('longitudinal_spacing', '縱向間距 (m)', '5.0'), ('altitude', '高度 (m)', '10.0'), ], 'GRID_SWEEP': [ ('line_spacing', '掃描線距 (m)', '5.0'), ('altitude', '高度 (m)', '10.0'), ], } # 建立所有參數列的 widget(先全部建好,切換時顯示/隱藏) self._param_widgets = {} # key → (label_widget, input_widget) self._param_rows = [] # 所有 row layout 對應的 container widget for mission_type, defs in self._param_defs.items(): for key, label_text, default in defs: if key in self._param_widgets: continue # 同名參數只建一次 row_w = QWidget() row_l = QHBoxLayout(row_w) row_l.setContentsMargins(0, 0, 0, 0) row_l.setSpacing(3) lbl = QLabel(label_text) lbl.setStyleSheet(LBL) inp = QLineEdit(default) inp.setStyleSheet(INPUT) inp.setFixedWidth(50) row_l.addWidget(lbl, 1) row_l.addWidget(inp) param_col.addWidget(row_w) self._param_widgets[key] = (row_w, inp) self._param_rows.append(row_w) # LEADER_FOLLOWER 專用:領隊下拉選單 self._leader_row = QWidget() leader_layout = QHBoxLayout(self._leader_row) leader_layout.setContentsMargins(0, 0, 0, 0) leader_layout.setSpacing(3) leader_lbl = QLabel("領隊") leader_lbl.setStyleSheet(LBL) self._leader_combo = QComboBox() self._leader_combo.setStyleSheet(COMBO) self._leader_combo.setFixedWidth(70) self._leader_combo.currentTextChanged.connect(self._on_leader_changed) leader_layout.addWidget(leader_lbl, 1) leader_layout.addWidget(self._leader_combo) param_col.addWidget(self._leader_row) param_col.addStretch() # 初始顯示對應的參數 self._update_param_visibility() # 當任務類型切換時更新參數顯示 self.type_combo.currentTextChanged.connect(self._update_param_visibility) # ── 組裝四欄:控制指令 > 任務規劃 > 任務參數 > 選取與分組 ── # 使用伸展因子 0 讓列根據內容自動調整寬度,而不是均等分配 cols.addLayout(left, 0) cols.addWidget(self._make_sep()) cols.addLayout(mid, 0) cols.addWidget(self._make_sep()) cols.addLayout(param_col, 0) cols.addWidget(self._make_sep()) cols.addLayout(right, 0) cols.addStretch() # 填充剩餘空間,使四列置左 layout.addLayout(cols) def update_drone_list(self): """更新無人機列表顯示""" if not self.group.drone_ids: self.drone_list_label.setText("尚未分配") self.drone_list_label.setStyleSheet("color: #888; font-size: 11px;") else: sorted_ids = sorted(self.group.drone_ids, key=lambda x: (x.split('_')[0], int(x.split('_')[1]))) self.drone_list_label.setText("、".join(sorted_ids)) self.drone_list_label.setStyleSheet( f"color: {self.group.color}; font-size: 11px; font-weight: bold;") self._refresh_leader_options() def _refresh_leader_options(self): """依目前群組成員重新填充領隊下拉選單,保留目前選擇若仍有效""" sorted_ids = sorted(self.group.drone_ids, key=lambda x: (x.split('_')[0], int(x.split('_')[1]))) current = self.group.leader_drone_id self._leader_combo.blockSignals(True) self._leader_combo.clear() self._leader_combo.addItems(sorted_ids) if current and current in sorted_ids: self._leader_combo.setCurrentText(current) elif sorted_ids: self._leader_combo.setCurrentText(sorted_ids[0]) self.group.leader_drone_id = sorted_ids[0] else: self.group.leader_drone_id = None self._leader_combo.blockSignals(False) def _on_leader_changed(self, drone_id): self.group.leader_drone_id = drone_id if drone_id else None def update_status(self): """更新任務狀態顯示""" state = self.group.state if self.group.planned_waypoints is None: self.status_label.setText("○ 未規劃") self.status_label.setStyleSheet("color: #888; font-size: 11px;") elif state == MissionState.RUNNING: self.status_label.setText("▶ 執行中") self.status_label.setStyleSheet("color: #4CAF50; font-size: 11px; font-weight: bold;") elif state == MissionState.PAUSED: self.status_label.setText("⏸ 已暫停") self.status_label.setStyleSheet("color: #FFA000; font-size: 11px; font-weight: bold;") else: n = len(self.group.drone_ids) total_wps = sum(len(wps) for wps in self.group.planned_waypoints['waypoints']) self.status_label.setText(f"● 已規劃 ({n}架/{total_wps}點)") self.status_label.setStyleSheet( f"color: {self.group.color}; font-size: 11px; font-weight: bold;") def _on_all_select_clicked(self): """全選按鈕點擊 - 發送信號給 gui.py 處理 toggle 邏輯""" self.select_all_requested.emit(self.group.group_id) def set_all_select_state(self, is_selected): """外部設置全選狀態(按鈕文本保持「全選/取消」)""" self._is_all_selected = is_selected def set_delete_enabled(self, enabled): """啟用或禁用刪除群組按鈕""" self.delete_group_btn.setEnabled(enabled) def _update_param_visibility(self, _=None): """根據當前任務類型,顯示/隱藏對應的參數列""" mission_type = self.type_combo.currentText() visible_keys = {d[0] for d in self._param_defs.get(mission_type, [])} for key, (row_w, _inp) in self._param_widgets.items(): row_w.setVisible(key in visible_keys) self._leader_row.setVisible(mission_type == 'LEADER_FOLLOWER') def get_mission_params(self): """讀取當前顯示的參數值,回傳 dict""" mission_type = self.type_combo.currentText() params = {} for key, _label, default in self._param_defs.get(mission_type, []): if key in self._param_widgets: _row_w, inp = self._param_widgets[key] try: params[key] = float(inp.text()) except ValueError: params[key] = float(default) return params def update_mission_info(self, center_lat, center_lon, target_lat, target_lon): """更新中心點 / 目標點顯示""" info_style = f"color: {self.group.color}; font-size: 11px; font-weight: bold;" self.center_label.setText(f"中心: {center_lat:.6f}°, {center_lon:.6f}°") self.center_label.setStyleSheet(info_style) self.target_label.setText(f"目標: {target_lat:.6f}°, {target_lon:.6f}°") self.target_label.setStyleSheet(info_style) def clear_mission_info(self): """清除中心點 / 目標點顯示""" self.center_label.setText("中心: --") self.center_label.setStyleSheet("color: #AAA; font-size: 11px;") self.target_label.setText("目標: --") self.target_label.setStyleSheet("color: #AAA; font-size: 11px;") def _on_takeoff(self): try: alt = float(self.alt_input.currentText()) except ValueError: alt = 10.0 self.takeoff_requested.emit(self.group.group_id, alt)