Compare commits

...

7 Commits

Author SHA1 Message Date
wenchun 415aed088a fix: 修正實飛 Circle 高度爆衝與 Leader-Follower 卡點
1. mavlinkROS2Nodes: position_gnss topic 改發 relative_altitude 而非 AMSL,
   讓 drone_gps['alt'] 在實飛與 SITL 都代表「相對 home 高度」
2. mission_planner: Circle stage1.z 改用 final_z(不再用 current_z 做中點插值),
   避免實飛時 current_z 是 AMSL 而把過渡高度衝到海拔值的一半
3. mission_executor: 到達判定改為 hover-stable(radius * 1.5 hysteresis +
   穩定計時),預設 arrival_radius 2→4m,解決實飛 GPS 抖動永遠進不了
   2m radius 的卡死問題
4. mission_executor: 新增 progress log,每 3 秒印一行距離與穩定度,
   方便實飛 debug 卡點問題
5. mission_group + gui: 暴露 arrival_radius / hover_stable_sec 給 GUI 任務參數面板,
   每次 start 重讀以支援中途調整

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2 weeks ago
ken910606 5231cffcb2 Update GUI 2.6.0: trajectory, color, window, serial JSON 3 weeks ago
Chiyu Chen 0ee7dd1fcf (hot_fix)
1. 把測試用參數關閉
2. 修正錯誤回傳值
3. 臨時加入 node 失效後 自動重啟功能
3 weeks ago
Chiyu Chen 61e283cd25 node重啟功能 與 定義檔優化
---
(Adding)
1. mavlinkROS2Nodes.py 各別 node 重啟的功能
2.  mainRochestrator.py 開發對應介面
(modify)
 1. 定義檔優化 移除 ackEnum.py 新增 ServiceAckResult.msg
3 weeks ago
ken910606 4e08b18a03 Update GUI 2.5.0: Drone reboot 4 weeks ago
ken910606 81de964778 Merge branch 'master' of http://140.120.108.238:49308/chiyu1468/AirTrapMine 4 weeks ago
ken910606 c75537a977 Update GUI 2.5.0: Drone reboot 4 weeks ago

@ -2,9 +2,15 @@
===
## 功能簡介
1. mavlink 多對多支援平台
2. 不允許進到 ros 系統有相同 sysid
3. 假設一台載具上所有 component 共用同一 socket
1. mavlink 多對多串流與交換
2. 支援 xbee 與 telemetry
3. 支援 udp 管理
4. ROS2 介面監控載具
===
使用限制
1. 不允許進到 ros 系統有相同 sysid
2. 假設一台載具上所有 component 共用同一 socket
===
## 運行環境

@ -247,7 +247,7 @@ class CommPanel(QWidget):
self.serial_baudrate_combo = QComboBox()
self.serial_baudrate_combo.addItems(['9600', '19200', '38400', '57600', '115200'])
self.serial_baudrate_combo.setCurrentText('57600')
self.serial_baudrate_combo.setCurrentText('115200')
self.serial_baudrate_combo.setFixedWidth(100)
_set_scaled_stylesheet(self.serial_baudrate_combo, """
QComboBox {

@ -83,7 +83,10 @@ class JsonTelemetryProcessor:
"position": {"lat": 24.0, "lon": 120.0},
"heading": 90
}
Serial JSON uses this same shape; only the transport/framing differs.
Serial JSON also accepts the compact UAV.py shape:
{"s": 1, "m": "GUIDED", "a": 1, "b": 85, "h": 10.0,
"v": 4.2, "p": [24.0, 120.0], "ypr": [90.0, 0.0, 0.0],
"g": 3, "d": [0.8, 1.2]}
"""
def _emit_json_connection_type(self, drone_id):
@ -103,19 +106,21 @@ class JsonTelemetryProcessor:
if not isinstance(data, dict):
return
system_id = data.get('system_id', data.get('sysid'))
system_id = data.get('system_id', data.get('sysid', data.get('s')))
if system_id is None:
return
drone_id = f"s{self.socket_id}_{system_id}"
self._emit_json_connection_type(drone_id)
mode = data.get('mode', data.get('mode_name'))
mode = data.get('mode', data.get('mode_name', data.get('m')))
state = {}
if mode is not None:
state['mode'] = mode
if 'armed' in data:
state['armed'] = data.get('armed')
elif 'a' in data:
state['armed'] = bool(data.get('a'))
if state:
self.signals.update_signal.emit('state', drone_id, state)
@ -144,6 +149,10 @@ class JsonTelemetryProcessor:
if 'battery_voltage' in data:
battery_data['voltage'] = data.get('battery_voltage')
self.signals.update_signal.emit('battery', drone_id, battery_data)
elif 'b' in data:
self.signals.update_signal.emit('battery', drone_id, {
'percentage': data.get('b')
})
pos = data.get('position')
if isinstance(pos, dict):
@ -159,6 +168,22 @@ class JsonTelemetryProcessor:
'lon': data.get('lon', data.get('longitude', 0)),
'alt': data.get('alt', data.get('altitude', 0))
})
elif isinstance(data.get('p'), (list, tuple)) and len(data.get('p')) >= 2:
dop = data.get('d') if isinstance(data.get('d'), (list, tuple)) else []
gps_data = {
'lat': data['p'][0],
'lon': data['p'][1],
'alt': data.get('h', 0)
}
if 'g' in data:
gps_data['fix_type'] = data.get('g')
if len(dop) >= 1:
gps_data['eph'] = dop[0]
gps_data['hdop'] = dop[0]
if len(dop) >= 2:
gps_data['epv'] = dop[1]
gps_data['vdop'] = dop[1]
self.signals.update_signal.emit('gps', drone_id, gps_data)
local = data.get('local_position', data.get('local_pose', data.get('local')))
if isinstance(local, dict):
@ -183,6 +208,29 @@ class JsonTelemetryProcessor:
self.signals.update_signal.emit('altitude', drone_id, {
'altitude': alt
})
elif 'h' in data:
height = data.get('h', 0.0)
self.signals.update_signal.emit('local_pose', drone_id, {
'x': 0.0,
'y': 0.0,
'z': height
})
self.signals.update_signal.emit('altitude', drone_id, {
'altitude': height
})
elif (
isinstance(data.get('p'), (list, tuple))
or 'lat' in data
or 'latitude' in data
):
self.signals.update_signal.emit('local_pose', drone_id, {
'x': 0.0,
'y': 0.0,
'z': 0.0
})
self.signals.update_signal.emit('altitude', drone_id, {
'altitude': 0.0
})
velocity = data.get('velocity')
if isinstance(velocity, dict):
@ -191,6 +239,12 @@ class JsonTelemetryProcessor:
'vy': velocity.get('vy', velocity.get('y', 0.0)),
'vz': velocity.get('vz', velocity.get('z', 0.0))
})
elif 'v' in data:
self.signals.update_signal.emit('velocity', drone_id, {
'vx': data.get('v', 0.0),
'vy': 0.0,
'vz': 0.0
})
attitude = data.get('attitude')
if isinstance(attitude, dict):
@ -200,12 +254,28 @@ class JsonTelemetryProcessor:
'yaw': attitude.get('yaw', 0.0),
'rates': attitude.get('rates', (0.0, 0.0, 0.0))
})
elif isinstance(data.get('ypr'), (list, tuple)) and len(data.get('ypr')) >= 3:
yaw, pitch, roll = data['ypr'][0], data['ypr'][1], data['ypr'][2]
self.signals.update_signal.emit('attitude', drone_id, {
'roll': roll,
'pitch': pitch,
'yaw': yaw,
'rates': (0.0, 0.0, 0.0)
})
hud = data.get('hud', {})
if not isinstance(hud, dict):
hud = {}
if 'heading' in data:
hud['heading'] = data.get('heading')
elif 'y' in data:
hud['heading'] = data.get('y')
elif isinstance(data.get('ypr'), (list, tuple)) and len(data.get('ypr')) >= 1:
hud['heading'] = data['ypr'][0]
if 'v' in data and 'groundspeed' not in hud:
hud['groundspeed'] = data.get('v')
if 'h' in data and 'alt' not in hud and 'altitude' not in hud:
hud['alt'] = data.get('h')
if hud:
self.signals.update_signal.emit('hud', drone_id, {
'heading': hud.get('heading', 0.0),
@ -229,6 +299,7 @@ class UDPMavlinkReceiver(threading.Thread):
self.connection_name = connection_name
self.monitor = monitor # 保存 monitor 引用
self.socket_id = monitor.get_next_socket_id() if monitor else 0
self._socket_id_released = False
self.running = False
self.sock = None
@ -258,6 +329,7 @@ class UDPMavlinkReceiver(threading.Thread):
finally:
if self.sock:
self.sock.close()
self._release_socket_id()
def process_mavlink_message(self, msg):
"""處理 MAVLink 訊息"""
@ -343,6 +415,12 @@ class UDPMavlinkReceiver(threading.Thread):
def stop(self):
"""停止接收器"""
self.running = False
self._release_socket_id()
def _release_socket_id(self):
if self.monitor and not self._socket_id_released:
self.monitor.release_socket_id(self.socket_id)
self._socket_id_released = True
class SerialMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
"""串口遙測接收器,可自動處理 MAVLink 或 WebSocket 格式 JSON。"""
@ -354,6 +432,7 @@ class SerialMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
self.connection_name = connection_name
self.monitor = monitor # 保存 monitor 引用
self.socket_id = monitor.get_next_socket_id() if monitor else 0
self._socket_id_released = False
self.source_type = 'Serial'
self.running = False
self.serial_conn = None
@ -402,6 +481,7 @@ class SerialMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
self.serial_conn.close()
except Exception:
pass
self._release_socket_id()
def _process_mavlink_byte(self, byte):
try:
@ -562,6 +642,12 @@ class SerialMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
self.serial_conn.close()
except Exception:
pass
self._release_socket_id()
def _release_socket_id(self):
if self.monitor and not self._socket_id_released:
self.monitor.release_socket_id(self.socket_id)
self._socket_id_released = True
class WebSocketMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
"""WebSocket MAVLink 接收器"""
@ -572,6 +658,7 @@ class WebSocketMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
self.connection_name = connection_name
self.monitor = monitor # 保存 monitor 引用
self.socket_id = monitor.get_next_socket_id() if monitor else 0 # 一次性分配 socket_id
self._socket_id_released = False
self.source_type = 'WS'
self.running = False
self.max_retries = 5
@ -629,6 +716,7 @@ class WebSocketMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
break
print(f"WebSocket client {self.connection_name} stopped")
self._release_socket_id()
def process_websocket_message(self, data):
"""處理 WebSocket 訊息"""
@ -637,6 +725,12 @@ class WebSocketMavlinkReceiver(threading.Thread, JsonTelemetryProcessor):
def stop(self):
"""停止接收器"""
self.running = False
self._release_socket_id()
def _release_socket_id(self):
if self.monitor and not self._socket_id_released:
self.monitor.release_socket_id(self.socket_id)
self._socket_id_released = True
class DroneMonitor(Node):
# Subscribe to drone ROS2 topics
@ -675,7 +769,7 @@ class DroneMonitor(Node):
# 【新增】Socket ID 重新分配機制 (從 0 開始)
# ================================================================================
self.socket_id_mapping = {} # {原始socket_id: 重新分配的socket_id}
self.socket_id_counter = 0 # 當前分配到的最大socket_id
self.active_socket_ids = set() # 目前通訊連線使用中的 socket_id
self.socket_id_lock = Lock() # 線程安全鎖
# ================================================================================
@ -708,23 +802,55 @@ class DroneMonitor(Node):
self.create_timer(1.0, self.scan_topics)
def get_next_socket_id(self):
"""获取下一个可用的 socket_id从 0 开始连续分配)"""
"""取得目前最小的未使用 socket_id從 0 開始)。"""
with self.socket_id_lock:
current_id = self.socket_id_counter
self.socket_id_counter += 1
return current_id
socket_id = 0
while socket_id in self.active_socket_ids:
socket_id += 1
self.active_socket_ids.add(socket_id)
return socket_id
def release_socket_id(self, socket_id):
"""釋放通訊連線使用的 socket_id讓後續連線可重用最小空缺。"""
try:
socket_id = int(socket_id)
except (TypeError, ValueError):
return
with self.socket_id_lock:
if socket_id in getattr(self, 'socket_id_mapping', {}).values():
return
if socket_id in getattr(self, 'sys_to_socket_id', {}).values():
return
self.active_socket_ids.discard(socket_id)
def get_or_assign_socket_id(self, original_socket_id):
"""根據原始 socket_id 分配或獲取對應的 socket_id從 0 開始連續分配)
同一個原始 socket_id 會得到同一個分配的 ID
"""ROS2 socket_id 映射。
有原始 socket_id=N 時優先使用 N N 已被其他通訊占用
才改用目前最小未使用 ID同一個原始 socket_id 會得到同一個映射
"""
original_socket_id = str(original_socket_id)
with self.socket_id_lock:
if original_socket_id not in self.socket_id_mapping:
# 分配新的 socket_id
self.socket_id_mapping[original_socket_id] = self.socket_id_counter
self.socket_id_counter += 1
try:
preferred_socket_id = int(original_socket_id)
except (TypeError, ValueError):
preferred_socket_id = None
if (
preferred_socket_id is not None
and preferred_socket_id >= 0
and preferred_socket_id not in self.active_socket_ids
):
socket_id = preferred_socket_id
else:
socket_id = 0
while socket_id in self.active_socket_ids:
socket_id += 1
self.active_socket_ids.add(socket_id)
self.socket_id_mapping[original_socket_id] = socket_id
return self.socket_id_mapping[original_socket_id]
@ -796,8 +922,7 @@ class DroneMonitor(Node):
# 为每个 sys_id 分配 socket_id如果还没有分配
# 注意:如果后续 summary 提供了 socket_id会使用 summary 的映射覆盖
if sys_id not in self.sys_to_socket_id:
# 暂时所有 ROS2 topic 共享同一个 socket_id = 0
self.sys_to_socket_id[sys_id] = 0
self.sys_to_socket_id[sys_id] = self.get_next_socket_id()
subs_attr = f'drone_{sys_id}_subs'
if not hasattr(self, subs_attr):
@ -1065,6 +1190,39 @@ class DroneMonitor(Node):
traceback.print_exc()
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):
"""Send setpoint position command"""
if drone_id not in self.setpoint_pubs:
@ -1161,8 +1319,12 @@ class DroneMonitor(Node):
# 從 summary 獲取原始 socket_id並映射到分配的 socket_id
original_socket_id = data.get('socket_id')
if original_socket_id is not None:
fallback_socket_id = self.sys_to_socket_id.pop(sys_id, None)
if fallback_socket_id is not None:
self.release_socket_id(fallback_socket_id)
# 使用原始 socket_id 獲取或分配統一的 socket_id
assigned_socket_id = self.get_or_assign_socket_id(original_socket_id)
self.sys_to_socket_id[sys_id] = assigned_socket_id
else:
# 如果沒有 socket_id使用 sys_to_socket_id 映射
assigned_socket_id = self.sys_to_socket_id.get(sys_id, 0)
@ -1328,7 +1490,7 @@ class DroneMonitor(Node):
'ping': msg.data
}
def start_serial_connection(self, port='/dev/ttyUSB0', baudrate=57600):
def start_serial_connection(self, port='/dev/ttyUSB0', baudrate=115200):
"""啟動串口遙測連接(自動辨識 MAVLink / JSON"""
connection_name = f"Serial_{port.replace('/', '_')}"
receiver = SerialMavlinkReceiver(port, baudrate, self.signals, connection_name, self)

@ -290,6 +290,7 @@ class DronePanel(QWidget):
# 分隔符
separator1 = QLabel(" - ")
separator1.setObjectName(f"{self.drone_id}_battery_sep1")
_set_scaled_stylesheet(separator1, "color: #DDD;")
# 顯示電壓
@ -299,6 +300,7 @@ class DronePanel(QWidget):
# 分隔符
separator2 = QLabel(" - ")
separator2.setObjectName(f"{self.drone_id}_battery_sep2")
_set_scaled_stylesheet(separator2, "color: #DDD;")
# 顯示電池節數 (S count)

@ -4,7 +4,8 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout
QWidget, QLabel, QSplitter, QScrollArea,
QSizePolicy, QTabWidget, QTableWidget, QTableWidgetItem,
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.QtGui import QColor, QFont, QPainter, QPen
import sys
@ -147,7 +148,7 @@ class ToggleSwitch(QWidget):
class ControlStationUI(QMainWindow):
planning_finished = pyqtSignal(object)
VERSION = '2.4.1'
VERSION = '2.6.0'
FONT_SCALE_MIN = 70
FONT_SCALE_MAX = 180
FONT_SCALE_DEFAULT = 100
@ -219,23 +220,24 @@ class ControlStationUI(QMainWindow):
self.socket_types = {}
self.socket_colors = {
'0': '#00BFFF', # 天藍色 (DeepSkyBlue)
'1': '#FFD700', # 金色 (Gold)
'2': '#FF6969', # 淺紅色 (Light Red)
'3': '#FF69B4', # 熱粉紅 (HotPink)
'4': '#00FA9A', # 中春綠 (MediumSpringGreen)
'5': '#9370DB', # 中紫色 (MediumPurple) - 串口
'6': '#FFA500', # 橙色 (Orange)
'7': '#20B2AA', # 淺海綠 (LightSeaGreen)
'8': '#7CFC00', # 草綠色 (LawnGreen)
'9': '#FF8C00', # 深橙色 (DarkOrange)
'default': '#AAAAAA' # 灰色
'0': '#00ACC1', # Cyan
'1': "#FDD835", # Yellow
'2': '#F8BBD0', # Pink
'3': '#90EE90', # Green
'4': '#FB8C00', # Orange
'5': '#8E24AA', # Purple
'6': '#1E88E5', # Blue
'7': '#E53935', # Red
'8': '#C0CA33', # Lime
'9': '#6D4C41', # Brown
'default': '#E0E0E0' # Light gray
}
self.drone_positions = {}
self.drone_headings = {}
# 初始化地圖
self.drone_map = DroneMap()
self.drone_map.set_socket_colors(self.socket_colors)
# 地圖更新獨立節流10Hz避免 WebEngine / JS map 拖慢 panel 更新
self.map_timer = QTimer()
@ -378,7 +380,7 @@ class ControlStationUI(QMainWindow):
verify_label = QLabel("模擬驗證")
verify_label.setStyleSheet("color: #CCC; font-size: 12px;")
self.verify_toggle = ToggleSwitch(checked=True)
self.verify_toggle = ToggleSwitch(checked=False)
group_header.addWidget(verify_label)
group_header.addWidget(self.verify_toggle)
@ -1102,6 +1104,7 @@ class ControlStationUI(QMainWindow):
panel.mode_change_requested.connect(self._handle_group_mode_change)
panel.arm_requested.connect(self._handle_group_arm)
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.select_all_requested.connect(self._handle_select_all_for_group)
panel.clear_group_requested.connect(self._handle_clear_group)
@ -1181,6 +1184,16 @@ class ControlStationUI(QMainWindow):
panel.update_drone_list()
panel.update_status()
self._sync_map_group_colors()
def _sync_map_group_colors(self):
"""同步每台 drone 的 group 顏色到地圖箭頭外框。"""
drone_group_colors = {}
for group in self.mission_groups.values():
for drone_id in group.selected_drone_ids:
drone_group_colors[drone_id] = group.color
self.drone_map.set_drone_group_colors(drone_group_colors)
def _get_drone_checkbox_style(self, blocked=False):
"""回傳 drone checkbox 樣式;被其他 group 佔用時顯示紅色填滿。"""
blocked_style = """
@ -1270,6 +1283,8 @@ class ControlStationUI(QMainWindow):
# 只有當操作目標組是 active 組時,才更新 UI
if group_id == self.active_group_id:
self.refresh_selection_ui()
else:
self._sync_map_group_colors()
panel = self.group_panels.get(group_id)
if panel:
@ -1295,12 +1310,18 @@ class ControlStationUI(QMainWindow):
self.drone_map.set_mission_mode(mission_type)
def _create_executor_for_group(self, group):
"""為群組建立 MissionExecutor"""
"""為群組建立 MissionExecutor讀取 panel 上的執行參數"""
exec_params = {}
panel = self.group_panels.get(group.group_id)
if panel and hasattr(panel, 'get_execution_params'):
exec_params = panel.get_execution_params()
executor = MissionExecutor(
sender=self.command_sender,
drone_gps=self.monitor.drone_gps,
monitor=self.monitor,
arrival_radius=2.0,
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,
)
executor.drone_waypoint_reached.connect(self.on_drone_waypoint_reached)
@ -1319,6 +1340,15 @@ class ControlStationUI(QMainWindow):
return
if group.executor is None:
self._create_executor_for_group(group)
else:
# 已存在的 executor 也要重讀執行參數,讓使用者調整能生效
panel = self.group_panels.get(group_id)
if panel and hasattr(panel, 'get_execution_params'):
ep = panel.get_execution_params()
group.executor.arrival_radius = ep.get(
'arrival_radius', group.executor.arrival_radius)
group.executor.hover_stable_sec = ep.get(
'hover_stable_sec', group.executor.hover_stable_sec)
group.executor.start(group.planned_waypoints)
panel = self.group_panels.get(group_id)
if panel:
@ -1426,6 +1456,39 @@ class ControlStationUI(QMainWindow):
future = self.monitor.takeoff_drone(drone_id, altitude)
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):
"""觸發地圖框選 → 框選完成後直接分配到該群組"""
self._pending_box_assign = group_id
@ -1457,6 +1520,8 @@ class ControlStationUI(QMainWindow):
if group_id == self.active_group_id:
self.refresh_selection_ui()
else:
self._sync_map_group_colors()
panel = self.group_panels.get(group_id)
if panel:
@ -1493,6 +1558,8 @@ class ControlStationUI(QMainWindow):
# 只有當操作目標組是 active 組時,才更新 UI
if group_id == self.active_group_id:
self.refresh_selection_ui()
else:
self._sync_map_group_colors()
panel = self.group_panels.get(group_id)
if panel:
@ -1517,6 +1584,8 @@ class ControlStationUI(QMainWindow):
# 只有當操作目標組是 active 組時,才更新 UI
if group_id == self.active_group_id:
self.refresh_selection_ui()
else:
self._sync_map_group_colors()
panel = self.group_panels.get(group_id)
if panel:
@ -1563,6 +1632,7 @@ class ControlStationUI(QMainWindow):
if self.group_tab_widget.count() > 0:
self.group_tab_widget.setCurrentIndex(0)
# _on_group_tab_changed 會自動設定新的 active group
self._sync_map_group_colors()
self.statusBar().showMessage(f"已刪除 Group {group_id}", 3000)
@ -2279,17 +2349,30 @@ class ControlStationUI(QMainWindow):
self.queue_overview_update(drone_id, 'armed', arm_text)
elif msg_type == 'battery':
voltage = data.get('voltage', 16)
cells = round(voltage / 3.95)
percentage = (voltage / cells - 3.7) / 0.5 * 100 if cells > 0 else 0
voltage = data.get('voltage')
cells = round(voltage / 3.95) if voltage is not None else None
percentage = (
(voltage / cells - 3.7) / 0.5 * 100
if voltage is not None and cells and cells > 0
else data.get('percentage', 0)
)
if percentage < 20: voltage_color = '#FF6464'
elif percentage < 50: voltage_color = '#FFA500'
else: voltage_color = '#FFFFFF'
percentage = data.get('percentage', percentage)
self.update_field(panel, drone_id, 'battery_pct', f"{percentage:.0f}%", voltage_color)
if voltage is not None:
self.update_field(panel, drone_id, 'battery_sep1', " - ")
self.update_field(panel, drone_id, 'battery_vol', f"{voltage:.2f}V")
self.update_field(panel, drone_id, 'battery_sep2', " - ")
self.update_field(panel, drone_id, 'battery_cells', f"{cells}S")
self.queue_overview_update(drone_id, 'battery', f"{voltage:.2f}V")
else:
self.update_field(panel, drone_id, 'battery_sep1', "")
self.update_field(panel, drone_id, 'battery_vol', "")
self.update_field(panel, drone_id, 'battery_sep2', "")
self.update_field(panel, drone_id, 'battery_cells', "")
self.queue_overview_update(drone_id, 'battery', f"{percentage:.0f}%")
elif msg_type == 'altitude':
altitude = data.get('altitude', 0)

@ -1,4 +1,6 @@
#!/usr/bin/env python3
import json
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtCore import QTimer, pyqtSignal, QObject, pyqtSlot
from PyQt6.QtWebChannel import QWebChannel
@ -17,6 +19,8 @@ class DroneMap:
self.map_loaded = False
self.pending_map_updates = {}
self.font_scale = 1.0
self.socket_colors = {}
self.drone_group_colors = {}
# 創建橋接對象
self.bridge = MapBridge()
@ -281,38 +285,37 @@ class DroneMap:
}, 100);
});
function createArrowIcon(color) {
function createArrowIcon(color, groupColor) {
const outlineColor = groupColor || 'transparent';
return L.divIcon({
className: 'drone-arrow',
html: `
<div style="
width: 30px; height: 30px;
width: 34px; height: 34px;
display: flex;
align-items: center;
justify-content: center;
">
<svg width="30" height="30" viewBox="0 0 30 30">
<polygon points="15,0 30,30 15,25 0,30" fill="${color || '#FF0000'}" />
<svg width="34" height="34" viewBox="0 0 34 34">
<polygon
points="17,3 31,31 17,26 3,31"
fill="${color || '#FF0000'}"
stroke="${outlineColor}"
stroke-width="3"
stroke-linejoin="round"
/>
</svg>
</div>
`,
iconSize: [30, 30],
iconAnchor: [15, 15]
iconSize: [34, 34],
iconAnchor: [17, 17]
});
}
function getColorBySocketId(id) {
if (id.startsWith("s0_")) return "#00BFFF";
if (id.startsWith("s1_")) return "#FFD700";
if (id.startsWith("s2_")) return "#FF6969";
if (id.startsWith("s3_")) return "#FF69B4";
if (id.startsWith("s4_")) return "#00FA9A";
if (id.startsWith("s5_")) return "#9370DB";
if (id.startsWith("s6_")) return "#FFA500";
if (id.startsWith("s7_")) return "#20B2AA";
if (id.startsWith("s8_")) return "#7CFC00";
if (id.startsWith("s9_")) return "#FF8C00";
return "#AAAAAA";
const match = /^s([^_]+)_/.exec(id || '');
const socketId = match ? match[1] : 'default';
return socketColors[socketId] || socketColors.default || "#E0E0E0";
}
function createIdIcon(id) {
@ -340,8 +343,11 @@ class DroneMap:
var markers = {};
var idLabels = {};
var trajectories = {};
const maxTrajectoryPoints = 300;
const maxTrajectoryPoints = 2000;
const trajectoryMinDistanceMeters = 0.05;
var trajectoryLines = {};
var socketColors = {};
var droneGroupColors = {};
var focusedId = null;
var initialized = false;
var trajectoryGroup = L.layerGroup().addTo(map);
@ -369,6 +375,16 @@ class DroneMap:
var drawStartPoint = null;
var mapDragStarted = false;
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 = [];
@ -693,7 +709,7 @@ class DroneMap:
color: color,
weight: 3,
opacity: 0.7,
smoothFactor: 1
smoothFactor: 0.1
}).addTo(trajectoryGroup);
}
}
@ -715,13 +731,20 @@ class DroneMap:
if (!markers[id]) return;
focusedId = id;
var latlng = markers[id].getLatLng();
autoCenteringMap = true;
map.flyTo(latlng, map.getZoom());
setTimeout(() => { autoCenteringMap = false; }, 500);
}
setInterval(() => {
if (focusedId && markers[focusedId]) {
if (Date.now() - lastManualMapMoveAt < 1000) {
return;
}
var latlng = markers[focusedId].getLatLng();
autoCenteringMap = true;
map.panTo(latlng);
setTimeout(() => { autoCenteringMap = false; }, 300);
}
}, 1000);
@ -729,7 +752,7 @@ class DroneMap:
if (markers[id]) {
const lastPos = markers[id].getLatLng();
const distance = lastPos.distanceTo([lat, lon]);
if (distance > 1) {
if (distance >= trajectoryMinDistanceMeters) {
addTrajectoryPoint(id, lat, lon);
}
@ -743,7 +766,7 @@ class DroneMap:
const color = getColorBySocketId(id);
markers[id] = L.marker([lat, lon], {
icon: createArrowIcon(color),
icon: createArrowIcon(color, droneGroupColors[id]),
rotationAngle: heading,
rotationOrigin: 'center'
})
@ -781,6 +804,33 @@ class DroneMap:
}
}
function setDroneGroupColors(groupColors) {
droneGroupColors = groupColors || {};
Object.keys(markers).forEach(id => {
markers[id].setIcon(
createArrowIcon(getColorBySocketId(id), droneGroupColors[id])
);
if (typeof markers[id].setRotationAngle === 'function') {
markers[id].setRotationAngle(markers[id].options.rotationAngle || 0);
}
});
}
function setSocketColors(colors) {
socketColors = colors || {};
Object.keys(markers).forEach(id => {
markers[id].setIcon(
createArrowIcon(getColorBySocketId(id), droneGroupColors[id])
);
if (typeof markers[id].setRotationAngle === 'function') {
markers[id].setRotationAngle(markers[id].options.rotationAngle || 0);
}
});
Object.keys(trajectoryLines).forEach(id => {
trajectoryLines[id].setStyle({ color: getColorBySocketId(id) });
});
}
function clearAllTrajectories() {
trajectories = {};
Object.values(trajectoryLines).forEach(line => {
@ -887,6 +937,8 @@ class DroneMap:
if ok:
self.map_loaded = True
self.set_font_scale(self.font_scale)
self.set_socket_colors(self.socket_colors)
self.set_drone_group_colors(self.drone_group_colors)
else:
_log("ERROR", "地圖載入失敗")
@ -901,7 +953,7 @@ class DroneMap:
js_commands = []
for drone_id, (lat, lon, heading) in self.pending_map_updates.items():
js_commands.append(f"updateDrone({lat:.6f}, {lon:.6f}, '{drone_id}', {heading:.1f});")
js_commands.append(f"updateDrone({lat:.8f}, {lon:.8f}, '{drone_id}', {heading:.1f});")
if js_commands:
combined_js = "\n".join(js_commands)
@ -914,6 +966,20 @@ class DroneMap:
if self.map_loaded:
self.map_view.page().runJavaScript("clearAllTrajectories();")
def set_drone_group_colors(self, drone_group_colors):
"""設定每台無人機的 group 外框顏色;未分組者不傳入即為透明。"""
self.drone_group_colors = dict(drone_group_colors or {})
if self.map_loaded:
payload = json.dumps(self.drone_group_colors)
self.map_view.page().runJavaScript(f"setDroneGroupColors({payload});")
def set_socket_colors(self, socket_colors):
"""設定 socket 顏色,供地圖箭頭本體與軌跡線使用。"""
self.socket_colors = dict(socket_colors or {})
if self.map_loaded:
payload = json.dumps(self.socket_colors)
self.map_view.page().runJavaScript(f"setSocketColors({payload});")
def focus_on_drone(self, drone_id):
"""聚焦到指定無人機"""
if self.map_loaded:

@ -43,6 +43,7 @@ class DroneTask:
__slots__ = (
'drone_id', 'sysid', 'waypoints', 'wp_index', 'done',
'sent_current_wp', 'fail_count', 'status', 'waiting_since',
'entered_radius_at', 'last_log_at',
)
def __init__(self, drone_id, sysid, waypoints):
@ -55,6 +56,8 @@ class DroneTask:
self.fail_count = 0
self.status = TaskStatus.NORMAL
self.waiting_since = 0.0 # monotonic time 進入 WAITING_AT_BARRIER 的瞬間
self.entered_radius_at = 0.0 # 進入 arrival_radius 的時間0 = 尚未進入
self.last_log_at = 0.0 # 上次印 distance log 的時間
@property
def current_target(self):
@ -89,14 +92,21 @@ class MissionExecutor(QObject):
mission_completed = pyqtSignal()
def __init__(self, sender, drone_gps, monitor=None,
arrival_radius=2.0, tick_rate_hz=2.0,
barrier_timeout_sec=20.0):
arrival_radius=4.0, tick_rate_hz=2.0,
barrier_timeout_sec=20.0,
hover_stable_sec=2.0,
progress_log_interval_sec=3.0):
super().__init__()
self.sender = sender
self.drone_gps = drone_gps
self.monitor = monitor # 用於失敗 fallback 到 LOITER
self.arrival_radius = arrival_radius
self.barrier_timeout_sec = barrier_timeout_sec
# hover-stable 判定:進入 radius 後須穩定停留 hover_stable_sec 秒才算到達,
# 容忍 GPS 抖動跨越邊界(用 radius * 1.5 作 hysteresis
self.hover_stable_sec = hover_stable_sec
self.hysteresis_factor = 1.5
self.progress_log_interval_sec = progress_log_interval_sec
self.state = MissionState.IDLE
self.tasks = {}
self.rendezvous_indices = set()
@ -140,7 +150,7 @@ class MissionExecutor(QObject):
"INFO",
f"任務已啟動: {len(self.tasks)} 架無人機, "
f"{total_wps} 個航點, "
f"到達半徑={self.arrival_radius}m, "
f"到達半徑={self.arrival_radius}m (hover-stable {self.hover_stable_sec}s), "
f"tick 週期={self._interval_ms}ms, "
f"barrier timeout={self.barrier_timeout_sec}s, "
f"{rv_info}",
@ -177,7 +187,7 @@ class MissionExecutor(QObject):
"""
now = time.monotonic()
# ---- Phase 1: 到達判定 ----
# ---- Phase 1: 到達判定hover-stable ----
for task in self.tasks.values():
if task.done or task.status == TaskStatus.FALLBACK_LOITER:
continue
@ -195,10 +205,32 @@ class MissionExecutor(QObject):
tgt_lat, tgt_lon, _ = target
distance = _haversine(gps['lat'], gps['lon'], tgt_lat, tgt_lon)
if distance >= self.arrival_radius:
# hover-stable 判定:
# - 首次進入 radius → 記錄時間
# - 在 radius * hysteresis 內持續 → 保留計時
# - 離開 hysteresis → 重置
# - 在 radius 內穩定 hover_stable_sec 秒 → 視為到達
hysteresis_radius = self.arrival_radius * self.hysteresis_factor
if distance < self.arrival_radius:
if task.entered_radius_at == 0.0:
task.entered_radius_at = now
stable_for = now - task.entered_radius_at
if stable_for < self.hover_stable_sec:
self._maybe_log_progress(task, now, distance, stable_for)
continue
elif distance < hysteresis_radius and task.entered_radius_at > 0.0:
# 還在 hysteresis 內,計時不重置(容忍 GPS 抖動跨越邊界)
self._maybe_log_progress(task, now, distance, now - task.entered_radius_at)
continue
else:
# 離開 hysteresis重置計時繼續等待到達
task.entered_radius_at = 0.0
self._maybe_log_progress(task, now, distance, 0.0)
continue
# 到達當前 wp_index
task.entered_radius_at = 0.0 # 重置給下個 wp 用
if (task.wp_index in self.rendezvous_indices
and task.wp_index < task.total_waypoints - 1):
# rendezvous 點 → 不推進,進入 barrier 等待
@ -245,11 +277,28 @@ class MissionExecutor(QObject):
self.mission_completed.emit()
_log("INFO", "任務全部完成")
def _maybe_log_progress(self, task, now, distance, stable_for):
"""週期性印出 drone 與當前 wp 的距離,方便實飛 debug 卡點問題。"""
if now - task.last_log_at < self.progress_log_interval_sec:
return
task.last_log_at = now
if stable_for > 0:
stable_info = f", 在內側 {stable_for:.1f}s/{self.hover_stable_sec:.1f}s"
else:
stable_info = ""
_log(
"INFO",
f"{task.drone_id} → WP {task.wp_index}: "
f"距離 {distance:.1f}m (半徑 {self.arrival_radius:.1f}m{stable_info})",
)
def _advance_waypoint(self, task, arrived_distance):
"""把 task 推進一個航點,重置發送旗標。不處理 barrier 邏輯。"""
task.wp_index += 1
task.sent_current_wp = False
task.fail_count = 0
task.entered_radius_at = 0.0
task.last_log_at = 0.0
if task.wp_index >= task.total_waypoints:
task.done = True
self.drone_waypoint_reached.emit(

@ -13,14 +13,13 @@ from mission_executor import MissionExecutor, MissionState
# 群組顏色(循環使用)
GROUP_COLORS = [
'#4A9EFF', # 藍
'#FF8C42', # 橘
'#56C87A', # 綠
'#E85D75', # 紅
'#B07CED', # 紫
'#F5C542', # 黃
'#42C9C9', # 青
'#FF6B9D', # 粉
"#E53935", # Deep Red
"#FB8C00", # Deep Orange
"#FDD835", # Deep Yellow
"#43A047", # Deep Green
"#1E88E5", # Deep Blue
"#8E24AA", # Deep Purple
"#D81B60", # Deep Pink
]
DEFAULT_MISSION_PARAM_VALUES = {
@ -33,9 +32,16 @@ DEFAULT_MISSION_PARAM_VALUES = {
'lateral_offset': '3.0',
'longitudinal_spacing': '5.0',
'line_spacing': '5.0',
# 執行參數(所有任務類型共用)
'arrival_radius': '4.0', # 到達半徑 (m),實飛建議 3~5SITL 可調小到 2
'hover_stable_sec': '2.0', # 在 radius 內穩定停留多久才視為到達 (s)
}
# 執行參數的 key 集合(所有 mission type 共用,不隨類型切換)
EXECUTION_PARAM_KEYS = ('arrival_radius', 'hover_stable_sec')
class MissionGroup:
"""單一任務群組的資料"""
@ -156,6 +162,7 @@ class GroupPanel(QWidget):
mode_change_requested = pyqtSignal(str, str) # group_id, mode
arm_requested = pyqtSignal(str) # group_id
takeoff_requested = pyqtSignal(str, float) # group_id, altitude
reboot_requested = pyqtSignal(str) # group_id
box_select_requested = pyqtSignal(str) # group_id — 框選直接分配
select_all_requested = pyqtSignal(str) # group_id — 全選直接分配
clear_group_requested = pyqtSignal(str) # group_id — 清除分組
@ -370,6 +377,23 @@ class GroupPanel(QWidget):
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()
# ============================
# 第四欄:任務參數
# ============================
@ -407,6 +431,12 @@ class GroupPanel(QWidget):
],
}
# 所有 mission type 共用的執行參數(不隨類型切換隱藏)
self._exec_param_defs = [
('arrival_radius', '到達半徑 (m)', self.default_params['arrival_radius']),
('hover_stable_sec', '停留判定 (s)', self.default_params['hover_stable_sec']),
]
# 建立所有參數列的 widget先全部建好切換時顯示/隱藏)
self._param_widgets = {} # key → (label_widget, input_widget)
self._param_rows = [] # 所有 row layout 對應的 container widget
@ -430,6 +460,23 @@ class GroupPanel(QWidget):
self._param_widgets[key] = (row_w, inp)
self._param_rows.append(row_w)
# 執行參數列(所有 mission type 共用,永遠顯示)
for key, label_text, default in self._exec_param_defs:
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)
@ -453,7 +500,7 @@ class GroupPanel(QWidget):
# 當任務類型切換時更新參數顯示
self.type_combo.currentTextChanged.connect(self._update_param_visibility)
# ── 組裝欄:控制指令 > 任務規劃 > 任務參數 > 選取與分組 ──
# ── 組裝:控制指令 > 任務規劃 > 任務參數 > 選取與分組 > 飛控操作 ──
# 使用伸展因子 0 讓列根據內容自動調整寬度,而不是均等分配
cols.addLayout(left, 0)
cols.addWidget(self._make_sep())
@ -462,7 +509,9 @@ class GroupPanel(QWidget):
cols.addLayout(param_col, 0)
cols.addWidget(self._make_sep())
cols.addLayout(right, 0)
cols.addStretch() # 填充剩餘空間,使四列置左
cols.addWidget(self._make_sep())
cols.addLayout(reboot_col, 0)
cols.addStretch() # 填充剩餘空間,使欄位置左
layout.addLayout(cols)
@ -527,15 +576,17 @@ class GroupPanel(QWidget):
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, [])}
mission_keys = {d[0] for d in self._param_defs.get(mission_type, [])}
exec_keys = {d[0] for d in self._exec_param_defs}
visible_keys = mission_keys | exec_keys
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"""
"""讀取當前任務類型的規劃參數,回傳 dict不含執行參數"""
mission_type = self.type_combo.currentText()
params = {}
for key, _label, default in self._param_defs.get(mission_type, []):
@ -547,6 +598,18 @@ class GroupPanel(QWidget):
params[key] = float(default)
return params
def get_execution_params(self):
"""讀取執行參數arrival_radius, hover_stable_sec 等),回傳 dict"""
params = {}
for key, _label, default in self._exec_param_defs:
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 set_param_value(self, key, value):
"""更新指定參數欄位的文字值。"""
if key not in self._param_widgets:

@ -193,11 +193,13 @@ class FormationPlanner:
final_y = center_y + radius * math.sin(angle_rad)
final_z = altitude
current_x, current_y, current_z = drone_positions[i]
current_x, current_y, _current_z = drone_positions[i]
# stage1 高度直接用 final_z先爬升到目標高度再水平集合
# 避免用 current_z 做中點插值current_z 在實飛時可能是 AMSL 而非相對 home會導致衝高
stage1_positions.append((
current_x + (final_x - current_x) * 0.5,
current_y + (final_y - current_y) * 0.5,
current_z + (final_z - current_z) * 0.5
final_z,
))
stage2_positions.append((final_x, final_y, final_z))

@ -14,6 +14,7 @@ find_package(builtin_interfaces REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/AttitudeRaw.msg"
"msg/GnssRaw.msg"
"msg/ServiceAckResult.msg"
"srv/MavPing.srv"
"srv/MavCommandLong.srv"
"srv/MavPositionTargetGlobalInt.srv"

@ -0,0 +1,43 @@
# ServiceAckResult.msg
# ACK code definition for command-like service responses.
# 這邊只是定義常數的檔案
#
# Design:
# - 0~10 : MAVLink MAV_RESULT compatible range (autopilot/firmware ACK) 大多不會用到 直接吃飛控回傳值
# - 50~89 : System-level ACK codes (local bridge/orchestrator status)
# - 90~99 : Unknown/reserved fallback
#
# Rule:
# - If FC ACK is received, return MAV_RESULT_* code.
# - If FC ACK is not reachable / local pipeline failed, return SYS_* code.
# ---- MAVLink MAV_RESULT (standard) ---- 詳細請自行去網路找 mavlink MAV_RESULT
uint8 MAV_RESULT_ACCEPTED=0 # 封包正常接受並執行
uint8 MAV_RESULT_TEMPORARILY_REJECTED=1 # 封包正常接受 但是飛控很忙 不執行 需要等待後重試
uint8 MAV_RESULT_DENIED=2 # 封包不正常 參數可能給錯了
uint8 MAV_RESULT_UNSUPPORTED=3 # 封包不正常 根本認不出是啥
uint8 MAV_RESULT_FAILED=4 # 封包正常接受 但是載具的狀態異常 不執行
uint8 MAV_RESULT_IN_PROGRESS=5
uint8 MAV_RESULT_CANCELLED=6
uint8 MAV_RESULT_COMMAND_LONG_ONLY=7
uint8 MAV_RESULT_COMMAND_INT_ONLY=8
uint8 MAV_RESULT_COMMAND_UNSUPPORTED_MAV_FRAME=9
uint8 MAV_RESULT_NOT_IN_CONTROL=10
# ---- System-level result codes (custom) ----
# 50 : 有接到預期的封包 但是訊息建議再確認
# 51 : some messages will not have obviously ack, so monitering feature message. however, we do NOT get the feature message. ex. 85/86 pair
#
# 52 : mavlink communication timeout 可能是通訊丟包了
# 53 : there already has a command being execution for this sysid, try later
# 54 : this sysid is not found
uint8 FCSYS_GET_EXPECTED_FEEDBACK=50
uint8 FCSYS_NO_EXPECTED_FEEDBACK=51
uint8 FCSYS_MAVLINK_TIMEOUT=52
uint8 FCSYS_BUSY=53
uint8 FCSYS_DEVICE_NOT_FOUND=54
# ---- Fallback ----
uint8 FCSYS_UNKNOWN=90

@ -1,31 +0,0 @@
'''
單獨出來定義回傳碼的意義
若跟飛控韌體有正常的通訊 (無論是否被拒絕) 則以 mavlink 定義的結果回傳
若跟飛控韌體無法通訊 才使用這邊的錯誤代碼
所以要避開重疊的號碼
這邊備註 Mavlink MAV_RESULT 參數意義
0 : 封包正常接受並執行
1 : 封包正常接受 但是正在忙不執行 需要等待後重試
4 : 封包正常接受 但是載具的狀態異常 不執行
2 : 封包不正常 參數可能給錯了
3 : 封包不正常 根本認不出是啥
'''
from enum import IntEnum
class serviceAckResult(IntEnum):
MAVLINK_TIMEOUT = 51 # mavlink communication timeout
MAVLINK_SEND_BUT_NO_EXP_STEAM = 30 # some messages will not have obviously ack, so monitering feature message. however, we do NOT get the feature message. ex. 85/86 pair
MAVLINK_BUSY = 53 # there already has a command being execution for this sysid, try later
MAVLINK_DEV_NOTFOUND = 54 # this sysid not fount
UNKNOWN = 90 # i did not capture this error, try read the logs

@ -37,6 +37,7 @@
- *self.bridge* 存放 mavlink_bridge 實例
- *self.plumber* 存放 serial_manager 實例
- *self.vehicle_registry* 存放 vehicle_registry 實例
- *self.fc_ros_manager* 存放 fc_ros_manager 實例
- *self.panel_thread* 面板的執行緒
- *self.panelState* 暫存面板與調配者互動的資料流動區
@ -240,8 +241,14 @@
8. 基礎載具流量觀測
9. 載具狀態收集與彙整
10. a. ros2 topic 應用開發介面 (基礎框架)
- GNSS (alt lan fix_type epv eph)
- attitude position_ned (pitch yaw row local_position)
- battery
b. ros2 service 應用開發介面 (基礎框架)
11. RTCM 轉發 - 訂閱 RTCM topic 轉為 MAVLink GPS_RTCM_DATA 廣播給所有載具
c. RTCM 轉發 - 訂閱 RTCM topic 轉為 MAVLink GPS_RTCM_DATA 廣播給所有載具
11. ros2 topic service rtcm Node 重啟功能
12. 可以定義 ROS_DOMAIN_ID
### 待開發功能
5-1. 建立 serial 連線 並可以對接收器下達AT指令

@ -29,7 +29,7 @@ from .utils import acquireSerial, acquirePort
from .utils.acquirePort import find_available_port
logger = setup_logger(os.path.basename(__file__))
PROJECT_VER = "v0.70"
PROJECT_VER = "v1.10"
class PanelState:
def __init__(self):
@ -177,11 +177,18 @@ class ControlPanel:
MenuNode("New+", "新增 Serial 連接埠", action = "LIST_SERIAL_RES"),
MenuNode("ListAll", "顯示並管理已連線的 Serial", action = "LIST_SERIAL_LINKS"),
]),
MenuNode("Vehicles Insp.", "檢視已連線的遠端載具", action = "INSPECT_VEHICLES"),
MenuNode("Vehicles Insp.", "檢視已連線的遠端載具", action = "SHOW_VEHICLES"),
MenuNode("Engineer Mode", "工程模式", children=[
MenuNode("Stop Manager", "停止 Mavlink 物件管理", "STOP_MANAGER"),
MenuNode("Stop Manager", "停止 Mavlink_Object 管理", "STOP_MANAGER"),
MenuNode("Stop Bridge", "停止 Mavlink-ROS 橋接", "STOP_BRIDGE"),
MenuNode("Stop Serial M.", "停止 Serial 端口轉接", "STOP_SERIAL_MANAGER"),
MenuNode("Stop Serial M.", "停止 Serial 端口", "STOP_SERIAL_MANAGER"),
MenuNode("Edit ROS Nodes", "管理 ROS NODE", children=[
MenuNode("Reboot Topics", "重建 ROS Topics", "REBOOT_ROS_TOPICS"),
MenuNode("Reboot Services", "重建 ROS Services", "REBOOT_ROS_SERVICES"),
MenuNode("Reboot RTCM", "重建 RTK RTCM", "REBOOT_ROS_RTCM"),
MenuNode("Stop ROS Manager", "停止 ROS 功能", "STOP_ROS_MANAGER"),
MenuNode("Start ROS Manager", "啟動 ROS 功能", "START_ROS_MANAGER"),
]),
]),
MenuNode("Shutdown", "關閉整個系統", children=[
MenuNode("Return", "繼續運行", "BACK"),
@ -384,6 +391,7 @@ class ControlPanel:
state.intoTERMINATION()
pre_panel_shutdown()
# related with -> UDP mavlink Object
elif selected.action == "TEXT_UDP_IP":
result = ControlPanel.input_dialog(stdscr, "請輸入監聽的 IP 位址: ")
if result is not None:
@ -412,6 +420,8 @@ class ControlPanel:
# menu_stack.pop()
# idx_stack.pop()
# related with -> Serial Connection
elif selected.action == "TEXT_BAUD_SERIAL":
result = ControlPanel.input_dialog(stdscr, "請輸入 Baud Rate (e.g., 9600, 115200): ")
if result is not None:
@ -477,12 +487,7 @@ class ControlPanel:
menu_stack.pop()
idx_stack.pop()
elif selected.action == "LIST_MAV_OBJECT":
# 動態生成 mavlink_object 列表選單
created_list_menu = self.create_object_list_menu(state, page=0)
menu_stack.append(created_list_menu)
idx_stack.append(0)
# related with -> 列表選單
elif selected.action in ("PREV_PAGE", "NEXT_PAGE"):
if hasattr(selected, 'page'):
current_list_menu = menu_stack[-1]
@ -507,6 +512,13 @@ class ControlPanel:
menu_stack.append(created_list_menu)
idx_stack.append(0)
# related with -> mavlink object list
elif selected.action == "LIST_MAV_OBJECT":
# 動態生成 mavlink_object 列表選單
created_list_menu = self.create_object_list_menu(state, page=0)
menu_stack.append(created_list_menu)
idx_stack.append(0)
elif selected.action == "INSPECT_MAV_OBJECT":
# 顯示物件詳細資訊
if hasattr(selected, 'socket_id'):
@ -551,6 +563,7 @@ class ControlPanel:
if target_id is not None:
cmd_q.put(("MAVOBJ_ADD_TARGET", selected.socket_id, target_id))
# related with -> Engineer Mode
elif selected.action == "STOP_MANAGER":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
@ -569,7 +582,39 @@ class ControlPanel:
continue # 只有在工程模式下才能操作
cmd_q.put("SHUTDOWN_SERIAL_MANAGER")
elif selected.action == "INSPECT_VEHICLES":
elif selected.action == "STOP_ROS_MANAGER":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put("STOP_ROS_MANAGER")
elif selected.action == "START_ROS_MANAGER":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put("START_ROS_MANAGER")
elif selected.action == "REBOOT_ROS_TOPICS":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put(("RESTART_ROS_NODE", "status_publisher")) # 參照一下 mavlinkROS2Nodes.NODE_KEYS
elif selected.action == "REBOOT_ROS_SERVICES":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put(("RESTART_ROS_NODE", "command_service")) # 參照一下 mavlinkROS2Nodes.NODE_KEYS
elif selected.action == "REBOOT_ROS_RTCM":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put(("RESTART_ROS_NODE", "rtcm_relay")) # 參照一下 mavlinkROS2Nodes.NODE_KEYS
# related with -> 進 bridge 的設備 列表
elif selected.action == "SHOW_VEHICLES":
# 進入載具檢視選單
cmd_q.put("UPDATE_VEHICLES_LIST")
created_list_menu = self.create_vehicles_list_menu(state, page=0)
@ -1173,6 +1218,8 @@ class Orchestrator:
def mainLoop(self):
logger.info("Main orchestrator started <-")
self.ros2_retry_time = None
self.ros2_manual_stop = False
try:
# while not self.stop_evt.is_set():
while self.panel_thread.is_alive():
@ -1204,6 +1251,17 @@ class Orchestrator:
else:
self.panelState.ros2_manager_state = 'Stopped'
# 這邊做一個簡易的重啟 掛掉要等待一秒再重啟 避免過度頻繁 # 算是高技術債的方式構建 但是未來有需要在還吧 (煙
if not self.ros2_manual_stop:
# logger.info(f"mark A {self.ros2_retry_time}")
if self.ros2_retry_time:
if (time.time() - self.ros2_retry_time) > 1:
self.fc_ros_manager.start()
self.fc_ros_manager.schedule_restart_node("command_service")
self.ros2_retry_time = None
else:
self.ros2_retry_time = time.time()
# B. 更新載具列表(從 vehicle_registry 獲取)
self._update_vehicles_list()
@ -1268,6 +1326,12 @@ class Orchestrator:
elif action == "UNREGISTER_VEHICLE":
sysid = cmd[1]
self.vehicle_registry.unregister(sysid)
elif action == "RESTART_ROS_NODE":
node_key = cmd[1]
if not self.fc_ros_manager.schedule_restart_node(node_key):
logger.warning(
f"RESTART_ROS_NODE failed: {node_key!r}"
)
elif cmd == "CREATE_UDP_INBOUND":
self.panelState.udp_info_temp["direction"] = "inbound"
@ -1277,12 +1341,19 @@ class Orchestrator:
self.create_udp_object()
elif cmd == "CREATE_SERIAL_PORT":
self.create_serial_port_object()
elif cmd == "SHUTDOWN_BRIDGE":
self.bridge.stop()
elif cmd == "SHUTDOWN_MANAGER":
self.manager.shutdown()
elif cmd == "SHUTDOWN_SERIAL_MANAGER":
self.plumber.shutdown()
elif cmd == "STOP_ROS_MANAGER":
self.fc_ros_manager.stop()
self.ros2_manual_stop = True
elif cmd == "START_ROS_MANAGER":
self.fc_ros_manager.start()
self.ros2_manual_stop = False
except queue.Empty:
pass
@ -1571,7 +1642,7 @@ def main():
if mo.MODULE_VER != "1.50":
print("Module Version Error! : mavlinkObkect")
version_check = False
if mros.MODULE_VER != "1.50":
if mros.MODULE_VER != "2.10":
print("Module Version Error! : mavlinkROS2Nodes")
version_check = False
if mvv.MODULE_VER != "1.00":
@ -1622,4 +1693,7 @@ if __name__ == "__main__":
2025.04.13
1. 加入各項模組的版本先驗 檢驗失敗就直接離開
2025.05.28
1. 工程模式 新增 Node 重啟的功能
'''

@ -37,7 +37,7 @@ import mavros_msgs.msg
# ROS2 Service imports
import fc_interfaces.srv as fcsrv
import fc_interfaces.msg as fcmsg
from .ackEnum import serviceAckResult
from fc_interfaces.msg import ServiceAckResult
# 自定義 imports
from . import mavlinkVehicleView as mvv
@ -45,7 +45,10 @@ from . import mavlinkObject as mo
from .utils import setup_logger
logger = setup_logger(os.path.basename(__file__))
MODULE_VER = "1.50"
MODULE_VER = "2.10"
FC_ROS_DOMAIN_ID = "0"
NODE_KEYS = ("status_publisher", "command_service", "rtcm_relay")
# ============================================================================
# VehicleStatusPublisher Node
@ -227,7 +230,9 @@ class VehicleStatusPublisher(Node):
msg.stamp = self.get_clock().now().to_msg()
msg.latitude = float(pos.latitude)
msg.longitude = float(pos.longitude)
msg.altitude = float(pos.altitude) if pos.altitude is not None else 0.0
# 發布「相對 home 高度」而非 AMSL下游 GUI / mission planner 一律以相對高度做運算與指令,
# 若這裡發 AMSLmsg.alt會在實飛時把海拔當成相對高度傳給飛控造成 Circle 高度衝高的 bug
msg.altitude = float(pos.relative_altitude) if pos.relative_altitude is not None else 0.0
# GPS 狀態資訊
gps = status.gps
@ -581,7 +586,7 @@ class MavlinkCommandService(Node):
self.handle_set_position_target_global_int
)
logger.info("MavlinkCommandService initialized")
# logger.info("MavlinkCommandService initialized")
def _index(self, target_sysid, target_compid):
@ -608,20 +613,19 @@ class MavlinkCommandService(Node):
藉由 event.set() 解開 service 中的 block
'''
return_tuple = mo.return_packet_ring.get()
# 確認 return_packet_ring 有資料
if return_tuple == None: return
socketid, timestamp, msg = return_tuple
sysid = msg.get_srcSystem()
_pending = self._pending_by_sysid.get(sysid)
# 確認是否有 service 在等待回應 若無直接 return 此封包也會被忽略
if _pending == None:
if _pending is None:
return
if _pending.match_fn(msg):
_pending.result_mav_msg = msg
_pending.event.set()
return
# ═══════════════════════════════════════════════════════════════════════
# Service Handler: 發送 TIMESYNC 可以作為模板
@ -706,20 +710,20 @@ class MavlinkCommandService(Node):
# 設定失效回應
response.success = False
response.message = "Unknown error"
response.ack_result = serviceAckResult.UNKNOWN
response.ack_result = ServiceAckResult.FCSYS_UNKNOWN
timeout_sec = request.timeout_sec
# 1) 確認是否已經有相同 sysid 的其他需求正在 pending
if request.target_sysid in self._pending_by_sysid:
response.message = f"sysid {request.target_sysid} already has pending request"
response.ack_result = serviceAckResult.MAVLINK_BUSY
response.ack_result = ServiceAckResult.FCSYS_BUSY
return response
# 2) 找到 socket 標地
socketObject = self._index(request.target_sysid, request.target_compid)
if socketObject is None:
response.message = "This system id not found."
response.ack_result = serviceAckResult.MAVLINK_DEV_NOTFOUND
response.ack_result = ServiceAckResult.FCSYS_DEVICE_NOT_FOUND
return response
# 3) 接收封包系統 的設定
@ -750,12 +754,13 @@ class MavlinkCommandService(Node):
# 5) 等待回應封包
if not evt.wait(timeout_sec):
response.message = "mavlink timeout - CommLONG"
response.ack_result = serviceAckResult.MAVLINK_TIMEOUT
response.ack_result = ServiceAckResult.FCSYS_MAVLINK_TIMEOUT
fail_skip = True
# 6) 處理回應封包
if not fail_skip:
ack_msg = _pending.result_mav_msg
# response.success = ServiceAckResult.FCSYS_MAVLINK_BREAK # dev 故意觸發錯誤用的
response.success = (ack_msg.result == 0) # mavutil.mavlink.MAV_RESULT_ACCEPTED
response.message = "" # 沒有消息就是好消息
response.ack_result = ack_msg.result
@ -779,20 +784,20 @@ class MavlinkCommandService(Node):
'''
# 設定失效回應
response.message = "Unknown error"
response.ack_result = serviceAckResult.UNKNOWN
response.ack_result = ServiceAckResult.FCSYS_UNKNOWN
timeout_sec = request.timeout_sec
# 1) 確認是否已經有相同 sysid 的其他需求正在 pending
if request.target_sysid in self._pending_by_sysid:
response.message = f"sysid {request.target_sysid} already has pending request"
response.ack_result = serviceAckResult.MAVLINK_BUSY
response.ack_result = ServiceAckResult.FCSYS_BUSY
return response
# 2) 找到 socket 標地
socketObject = self._index(request.target_sysid, request.target_compid)
if socketObject is None:
response.message = "This system id not found."
response.ack_result = serviceAckResult.MAVLINK_DEV_NOTFOUND
response.ack_result = ServiceAckResult.FCSYS_DEVICE_NOT_FOUND
return response
# 3) 接收封包系統 的設定
@ -827,7 +832,7 @@ class MavlinkCommandService(Node):
# 5) 等待回應封包
if not evt.wait(timeout_sec):
response.message = "mavlink timeout - SET POSITION 86"
response.ack_result = MAVLINK_SEND_BUT_NO_EXP_STEAM
response.ack_result = ServiceAckResult.FCSYS_NO_EXPECTED_FEEDBACK
fail_skip = True
# 6) 處理回應封包
@ -835,7 +840,7 @@ class MavlinkCommandService(Node):
ack_msg = _pending.result_mav_msg
response.message = ""
response.ack_result = 0
response.ack_result = ServiceAckResult.MAV_RESULT_ACCEPTED
response.r_time_boot_ms = ack_msg.time_boot_ms
response.r_type_mask = ack_msg.type_mask
response.r_lat_int = ack_msg.lat_int
@ -952,11 +957,10 @@ class MavlinkCommandService(Node):
return response
def stop(self):
"""停止服務"""
self.running = False
logger.info("MavlinkCommandService stopped")
# logger.info("MavlinkCommandService stopped")
# ============================================================================
@ -1025,7 +1029,7 @@ class RtcmRelay(Node):
rtcm_qos,
)
logger.info("RtcmRelay initialized")
# logger.info("RtcmRelay initialized")
# ── callback ──────────────────────────────────────────────────────
@ -1129,7 +1133,7 @@ class RtcmRelay(Node):
def stop(self):
"""停止轉發"""
self.running = False
logger.info("RtcmRelay stopped")
# logger.info("RtcmRelay stopped")
# ============================================================================
@ -1145,7 +1149,7 @@ class fc_ros_manager:
stop 是停下止 ROS2 nodes 的運行但不銷毀節點實例允許後續再次 start
shutdown 是完全關閉 ROS2 並銷毀節點實例
管理三個獨立的 ROS2 Node
管理三個獨立的 ROS2 Node :
- VehicleStatusPublisher
- MavlinkCommandService
- RtcmRelay
@ -1156,6 +1160,9 @@ class fc_ros_manager:
def __init__(self):
self.initialized = False
self.running = False
self._restarting = False
self._pending_restart: Optional[str] = None
self._restart_lock = threading.Lock()
# node 實例
self.status_publisher: Optional[VehicleStatusPublisher] = None
@ -1173,7 +1180,7 @@ class fc_ros_manager:
return False
try:
# 初始化 ROS2
os.environ.setdefault("ROS_DOMAIN_ID", FC_ROS_DOMAIN_ID)
if not rclpy.ok():
rclpy.init(args=None)
@ -1209,6 +1216,10 @@ class fc_ros_manager:
try:
self.running = True
self.status_publisher.running = True
self.command_service.running = True
self.rtcm_relay.running = True
self.spin_thread = threading.Thread(
target=self._spin_executor,
daemon=True,
@ -1224,21 +1235,102 @@ class fc_ros_manager:
self.running = False
return False
# 給 orchestrator 呼叫的函數
def schedule_restart_node(self, node_key: str) -> bool:
"""由 orchestrator 排程單一 node 重啟(於 spin thread 下一輪執行)"""
if node_key not in NODE_KEYS:
logger.warning(f"schedule_restart_node: unknown key {node_key!r}")
return False
if not self.running:
logger.warning("schedule_restart_node: fc_ros_manager not running")
return False
if not self.spin_thread or not self.spin_thread.is_alive():
logger.warning("schedule_restart_node: spin thread not alive")
return False
with self._restart_lock:
self._pending_restart = node_key
logger.info(f"schedule_restart_node: queued {node_key}")
return True
# 循環執行的地方
def _spin_executor(self):
"""在 thread 中運行的 executor"""
try:
# logger.info("ROS2 executor spinning...")
while self.running:
self.executor.spin_once(timeout_sec=0.1)
pending = None
with self._restart_lock:
if self._pending_restart:
pending = self._pending_restart
self._pending_restart = None
if pending:
self._restart_node(pending)
elif self.command_service and not self._restarting:
self.command_service.return_router()
except Exception as e:
logger.error(f"fc_ros_manager error in spinning executor: {e}")
finally:
self.running = False
def _restart_node(self, node_key: str) -> bool:
"""僅由 spin thread 呼叫 : destroy 並重建單一 node"""
old_node = getattr(self, node_key, None)
if old_node is None:
logger.error(f"_restart_node: {node_key} is None")
return False
# 重置前保留舊有或處理設定
saved_topic_intervals = None
if node_key == "status_publisher":
saved_topic_intervals = dict(old_node.rate_controller.topic_intervals)
# 如果是 command_service 清除所有等待的 pending 並且清除 return message
if node_key == "command_service":
old_node._pending_by_sysid.clear()
for sockObj in mo.mavlink_object.mavlinkObjects.values():
sockObj.set_return_message_types([])
try:
self._restarting = True
# 停止運作 移出執行迴圈 銷毀資源
old_node.stop()
self.executor.remove_node(old_node)
old_node.destroy_node()
# 建立新的 node
if node_key == "status_publisher":
new_node = VehicleStatusPublisher()
if saved_topic_intervals is not None:
new_node.rate_controller.topic_intervals = saved_topic_intervals
elif node_key == "command_service":
new_node = MavlinkCommandService()
elif node_key == "rtcm_relay":
new_node = RtcmRelay()
else:
return False
# 加回去管理系統中
setattr(self, node_key, new_node)
self.executor.add_node(new_node)
logger.info(f"_restart_node: {node_key} restarted")
return True
except Exception as e:
logger.error(f"_restart_node failed for {node_key}: {e}")
return False
finally:
self._restarting = False
def stop(self):
"""停止 ROS2 nodes"""
if not self.running:
logger.warning("fc_ros_manager not running")
# if not self.running:
# logger.warning("fc_ros_manager not running")
# return False
if not self.spin_thread:
logger.warning("fc_ros_manager without thread to stop")
return False
try:
@ -1289,7 +1381,7 @@ class fc_ros_manager:
except Exception as e:
logger.error(f"Error during shutdown: {e}")
def get_status(self) -> dict:
def get_status(self):
return {
'initialized': self.initialized,
'running': self.running,
@ -1299,6 +1391,12 @@ class fc_ros_manager:
}
# ============================================================================
# 全域實例
# ============================================================================
@ -1331,8 +1429,13 @@ ros2_manager = fc_ros_manager()
2. 實作過期丟棄(1s)blake2s hash 去重最短轉發間隔節流分片(180 bytes/)
3. 接入 fc_ros_manager initialize/start/stop/shutdown 生命週期
2026.05.22
1. initialize() 設定 ROS_DOMAIN_ID ( FC_ROS_DOMAIN_ID )
2. schedule_restart_node / _restart_node : 手動重啟單一 node (spin thread 內執行
3. orchestrator cmd: ("RESTART_ROS_NODE", node_key), node_key NODE_KEYS
TODO
1. service 部分會需要跟 mavlinkobject 大量互動 也許需要考慮對方的生命週期
2. 還沒測試 如果有失效狀態讓 fc_ros_manager 中斷了 如何恢復的問題
'''

@ -14,6 +14,7 @@ from fc_interfaces.srv import MavCommandLong
COMMAND_DO_SET_MODE = 176
COMMAND_NAV_LAND = 21
COMMAND_NAV_TAKEOFF = 22
COMMAND_PREFLIGHT_REBOOT_SHUTDOWN = 246
COMMAND_COMPONENT_ARM_DISARM = 400
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
@ -191,6 +192,28 @@ class CommandLongClient(Node):
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 的非阻塞調用)
# 這些方法在 ThreadPoolExecutor 中運行同步版本,以避免阻塞事件循環
@ -353,6 +376,29 @@ class CommandLongClient(Node):
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(
self,
*,

@ -1,32 +1,25 @@
備選需要的功能
- serail 對於 telemetry 的支援
- serial_manager.serial_object.transport 這些變數可能不需要
不用動
- mavlink_object 的 send_message 確認一下 mavlink_bridge 的 _send_to_socket 是不是應該做成 async
- 不同 socket 上面有重複的 sysid 分開儲存 (不做 不允許sysid重複)
這一步
希望 ackEnum 可以擺到正確的位置
node 增加 GNSS 訊息 (2d fix 3d fix rtk float...)
RTK
跟 RTK2go 抓取列表
從特定 mount point 得到數據
做一個 ros2 service(action?) 接到數據並包裝
跟 RTK2go 抓取列表 Done
從特定 mount point 得到數據 Done
做一個 ros2 service 接到數據並包裝
下一步
NODE 重啟
可以 refresh node topic
重啟做好了 但是還沒自動化
下下一步
整合 xbee api-api 功能
後面
rssi 資訊提取s
mavlink timeout - CommLONG 這個是 mavlink bus 沒有回應 可能是丟包了
自己的常用指令
@ -39,8 +32,18 @@ python -m someotherpkg.src.example_change_mode
python -m fc_network_module.fc_network_module.ntrip_client --ros-args -p mountpoint:=No1bio_02 -p username:=chiyu1468@hotmail.com
python -m fc_network_module.fc_network_module.ntrip_client --ros-args -p host:=210.241.63.193 -p port:=81 -p mountpoint:=2020_GNSS -p username:=uavlab6061 -p password:=iamsupersmart
export ROS_DOMAIN_ID=13
ros2 daemon stop
ros2 daemon start
ros2 topic list
ros2 topic echo /fc_network/vehicle/sys3/battery
ros2 topic echo hz /fc_network/vehicle/sys3/battery
ros2 topic bw /fc_network/vehicle/sys3/attitude
ros2 topic hz /fc_network/vehicle/sys3/attitude
/home/picars/ardupilot/build/sitl/bin/arducopter -S --model + --speedup 1 --slave 0 --defaults /home/picars/ardupilot/Tools/autotest/default_params/copter.parm --sim-address=127.0.0.1 -I3 --sysid 7
@ -51,7 +54,7 @@ ros2 service call /mavlink/add_two_ints example_interfaces/srv/AddTwoInts "{a: 5
ros2 service call /fc_network/vehicle/mav_ping fc_interfaces/srv/MavPing "{target_sysid: 3, target_compid: 0, ping_seq: 1}"
ros2 service call /fc_network/vehicle/send_command_long fc_interfaces/srv/MavCommandLong "{target_sysid: 3, target_compid: 0, command: 176, confirmation: 0, param1: 1, param2: 4, param3: 0,param4: 0,param5: 0,param6: 0,param7: 0, timeout_sec: 2}"
ros2 service call /fc_network/vehicle/send_command_long fc_interfaces/srv/MavCommandLong "{target_sysid: 3, target_compid: 0, command: 176, confirmation: 0, param1: 1, param2: 4, timeout_sec: 2}"
ros2 service call /fc_network/vehicle/send_command_long fc_interfaces/srv/MavCommandLong "{target_sysid: 10, target_compid: 0, command: 176, confirmation: 0, param1: 1, param2: 4, timeout_sec: 2}"
ros2 service call /fc_network/vehicle/pos_global_int fc_interfaces/srv/MavPositionTargetGlobalInt "{target_sysid: 3, target_compid: 1, coordinate_frame: 6, type_mask: 3576, lat_int: -35376655, lon_int: 149157011, alt: 20.0, timeout_sec: 5.0}"
@ -60,10 +63,4 @@ sudo tcpdump -i lo 'udp dst port 14561' -X
sudo tcpdump -i lo 'udp dst port 14550' -X -vv
sudo tcpdump -i lo -X udp port 14550
幾個要討論的
1. 專案文件中 .mat 檔案是幹嘛的?
2. GUI 的 print [Panel/Map Update] 11 Hz 希望關閉
3. readme 那串文字來源? 用途?
4. GUI介面 加一下海拔高 跟 相對高 速度跟位置 變更為 XY速度 XY位置
5. pitch yaw roll 出來了 弄一下
6.

Loading…
Cancel
Save