Compare commits
19
Commits
7d0368e7ba
...
ken
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2c642d6c | ||
|
|
2c38376f9c | ||
|
|
7ae4c56f86 | ||
|
|
20e8397f5a | ||
|
|
a700e62de1 | ||
|
|
f28dcca361 | ||
|
|
b0f1bd56f5 | ||
|
|
2990de2f3f | ||
|
|
cf213fc556 | ||
|
|
017dd4923d | ||
|
|
f34693a400 | ||
|
|
989d3ad2d2 | ||
|
|
a1efcf5664 | ||
|
|
a303a3538e | ||
|
|
cb58ba2121 | ||
|
|
dbcd76be35 | ||
|
|
71321d4839 | ||
|
|
c4f178bb75 | ||
|
|
eb2f0af58e |
@@ -1,43 +1,46 @@
|
||||
這是天雷系統的專案
|
||||
|
||||
|
||||
|
||||
===
|
||||
專案核心框架
|
||||
## 功能簡介
|
||||
1. mavlink 多對多支援平台
|
||||
2. 不允許進到 ros 系統有相同 sysid
|
||||
3. 假設一台載具上所有 component 共用同一 socket
|
||||
|
||||
===
|
||||
## 運行環境
|
||||
|
||||
### 專案核心框架
|
||||
1. ROS2 Humble
|
||||
2. Python 3.8.10
|
||||
|
||||
### 必要相依套件及版本
|
||||
- Python
|
||||
[Package] fc_network_adapter
|
||||
1. pymavlink -> Version: 2.4.42
|
||||
2. conda-forge 中的 pyserial-asyncio
|
||||
3. importlib_metadata -> Version: 8.5.0
|
||||
4. setuptools -> Version: 58.2.0 (版本太新不行)
|
||||
5. pyserial-asyncio
|
||||
[Package] GUI
|
||||
1. testresources
|
||||
2. websockets
|
||||
3. PyQt6
|
||||
4. PyQt6-WebEngine
|
||||
|
||||
===
|
||||
必要相依套件 順便記錄我開發時的環境版本
|
||||
|
||||
Python
|
||||
1. pymavlink -> Version: 2.4.42
|
||||
2. conda-forge 中的 pyserial-asyncio
|
||||
3. importlib_metadata -> Version: 8.5.0
|
||||
4. setuptools -> Version: 58.2.0 (版本太新不行)
|
||||
5. pyserial-asyncio
|
||||
|
||||
|
||||
ROS2
|
||||
- ROS2
|
||||
1. source ~/ros2_humble/install/setup.bash
|
||||
2.
|
||||
2. geographic_info (https://github.com/ros-geographic-info/geographic_info.git) 已經作為 submodule 放在 external
|
||||
3. angles (https://github.com/ros/angles.git) 已經作為 submodule 放在 external
|
||||
4. mavros_msgs (https://github.com/mavlink/mavros) 這個專案中的一個資料夾 這邊手動複製的
|
||||
|
||||
===
|
||||
功能簡介
|
||||
1. mavlink 多對多支援平台
|
||||
2. 不允許進到 ros 系統有相同 sysid
|
||||
3. 假設所有 component 共用同一 socket
|
||||
|
||||
===
|
||||
開發用輔助專案
|
||||
### 開發用輔助專案
|
||||
1. Gazebo Garden
|
||||
2. Ardupilot
|
||||
|
||||
===
|
||||
依賴的 ROS 庫
|
||||
1. https://github.com/ros-geographic-info/geographic_info.git 記得要搞 ros2 版本的
|
||||
2. https://github.com/ros/angles.git
|
||||
3. mavros_msgs 是 https://github.com/mavlink/mavros 這個專案中的一個資料夾 這邊手動複製的
|
||||
|
||||
## 使用說明
|
||||
Clone 專案後 請先執行這些指令
|
||||
```bash
|
||||
# 1.同步 submodule
|
||||
@@ -49,45 +52,53 @@ colcon build --packages-select angles geographic_msgs
|
||||
colcon build --packages-select mavros_msgs # 這個依賴前面的
|
||||
colcon build --packages-select fc_interfaces # 自己定義的
|
||||
|
||||
```
|
||||
1.
|
||||
主要專案 fc_network_adapter 請一律在 ~/AirTrapMine/src/ 路徑下 以模組化啟動程式
|
||||
```bash
|
||||
# 記得先開啟 依賴 Package 到 overlay
|
||||
. ./install/local_setup.bash
|
||||
# 範例
|
||||
cd ~/AirTrapMine/src/ # 這是範例!!!
|
||||
python -m fc_network_adapter.fc_network_adapter.mainOrchestrator
|
||||
python -m fc_network_adapter.tests.demo_integration
|
||||
python -m someotherpkg.src.example_wholeMoving
|
||||
```
|
||||
|
||||
2.
|
||||
GUI 介面
|
||||
在 ~/AirTrapMine/src/GUI 路徑下 直接啟動
|
||||
```bash
|
||||
cd ~/AirTrapMine/src/GUI
|
||||
python gui.py
|
||||
```
|
||||
|
||||
===
|
||||
Package 簡述
|
||||
<<<<<<< HEAD
|
||||
|
||||
===
|
||||
簡述0328
|
||||
主要是透過test_transform這隻程式執行fdm_switch_example_one_with_remote_forwarding這個副函式,透過UDP將封包轉接到Matlab,並在Matlab進行演算法迭代後,打包JSON檔,並再次透過port口傳回封包,傳回來的封包會在close_loop這隻程式被解析,並提取其中有關velocity的資料儲存成變數,以mavlink的方式打入Ardupilot,以實現封閉迴路的構思。
|
||||
=======
|
||||
## 資料夾說明
|
||||
1. unitdev 為各自協作者做開發時的測試區
|
||||
01 -> 晉凱(ken)
|
||||
02 -> 其宇(chiyu)
|
||||
03 -> 文鈞
|
||||
04 -> 倫渝
|
||||
2. fc_network_adapter
|
||||
2. fc_network_adapter (核心)
|
||||
建立、維護與飛控韌體的連接
|
||||
構築 mavlink 封包
|
||||
處理無線模組的通訊格式 (XBee)
|
||||
--同時處理與 Gazebo 的 ardupilot_plugin 溝通的 FDM/JSON 訊息 (移除)--
|
||||
3. fc_interfaces
|
||||
自定義的介面檔
|
||||
3. fc_interfaces (重要)
|
||||
自定義的 ROS2 介面檔
|
||||
4. fc_network_apps
|
||||
與 fc_network_adapter 銜接做高階功能包裝的應用小程式 利於開發GUI或其他應用
|
||||
|
||||
使用者的外層包裝
|
||||
5. someotherpkg
|
||||
如何使用 fc_network_apps 的範例檔案
|
||||
6. GUI
|
||||
由 PyQt6 開發的互動式介面
|
||||
N. logs 是執行時期的記錄檔
|
||||
|
||||
===
|
||||
主要專案 fc_network_adapter 請一律在 ~/AirTrapMine/src/ 資料夾下 以模組化啟動程式
|
||||
|
||||
例如 在 ~/AirTrapMine/src/ 資料夾下
|
||||
```bash
|
||||
# 記得先開啟 依賴 Package 到 overlay
|
||||
. ./install/local_setup.bash
|
||||
# 範例
|
||||
python -m fc_network_adapter.fc_network_adapter.mainOrchestrator
|
||||
python -m fc_network_adapter.tests.demo_integration
|
||||
|
||||
```
|
||||
|
||||
>>>>>>> chiyu
|
||||
簡述0328
|
||||
主要是透過test_transform這隻程式執行fdm_switch_example_one_with_remote_forwarding這個副函式,透過UDP將封包轉接到Matlab,並在Matlab進行演算法迭代後,打包JSON檔,並再次透過port口傳回封包,傳回來的封包會在close_loop這隻程式被解析,並提取其中有關velocity的資料儲存成變數,以mavlink的方式打入Ardupilot,以實現封閉迴路的構思。
|
||||
|
||||
+170
-32
@@ -4,10 +4,14 @@ import math
|
||||
import re
|
||||
import threading
|
||||
from threading import Lock
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import asyncio
|
||||
import websockets
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
from pymavlink import mavutil
|
||||
from geometry_msgs.msg import Point, Vector3
|
||||
from sensor_msgs.msg import BatteryState, NavSatFix, Imu
|
||||
@@ -15,6 +19,23 @@ from std_msgs.msg import Float64, String
|
||||
from mavros_msgs.msg import State, VfrHud
|
||||
from mavros_msgs.srv import CommandBool, CommandTOL
|
||||
|
||||
# 確保 src 目錄在 Python 路徑中(用於 fc_network_apps 導入)
|
||||
_src_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _src_path not in sys.path:
|
||||
sys.path.insert(0, _src_path)
|
||||
|
||||
# 導入 fc_network_apps 的 longCommand(統一的 MAV_CMD_* API)
|
||||
try:
|
||||
from fc_network_apps.longCommand import CommandLongClient
|
||||
except ImportError as e:
|
||||
import traceback
|
||||
print(f"⚠️ 警告: 無法導入 CommandLongClient")
|
||||
print(f" 错误: {e}")
|
||||
print(f" 这通常表示 ROS2 的 fc_interfaces 包未被编译或未正确安装")
|
||||
print(f" 完整堆栈跟踪:")
|
||||
traceback.print_exc()
|
||||
CommandLongClient = None
|
||||
|
||||
class DroneSignals(QObject):
|
||||
update_signal = pyqtSignal(str, str, object) # (msg_type, drone_id, data)
|
||||
|
||||
@@ -398,8 +419,13 @@ class WebSocketMavlinkReceiver(threading.Thread):
|
||||
|
||||
class DroneMonitor(Node):
|
||||
# Subscribe to drone ROS2 topics
|
||||
_instance = None # Singleton pattern to prevent duplicate nodes
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('drone_monitor')
|
||||
# Use a unique node name with timestamp to avoid conflicts on restart
|
||||
import time
|
||||
node_name = f'drone_monitor_{int(time.time() * 1000) % 100000}'
|
||||
super().__init__(node_name)
|
||||
self.signals = DroneSignals()
|
||||
self.drone_topics = {}
|
||||
self.lock = Lock()
|
||||
@@ -440,6 +466,17 @@ class DroneMonitor(Node):
|
||||
# ================================================================================
|
||||
self.serial_receivers = []
|
||||
|
||||
# ================================================================================
|
||||
# 【新增】初始化 CommandLongClient(持久化,不會每次調用都創建/銷毀)
|
||||
# ================================================================================
|
||||
self.command_long_client = None
|
||||
try:
|
||||
self.command_long_client = CommandLongClient()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 警告: 無法初始化 CommandLongClient: {e}")
|
||||
self.command_long_client = None
|
||||
# ================================================================================
|
||||
|
||||
# 主题检测定时器
|
||||
self.create_timer(1.0, self.scan_topics)
|
||||
|
||||
@@ -536,44 +573,145 @@ class DroneMonitor(Node):
|
||||
|
||||
setattr(self, f'drone_{sys_id}_subs', subs)
|
||||
|
||||
# ================================================================================
|
||||
# 【新增】模式名稱到 custom_mode 值的映射(基於 Copter 模式)
|
||||
# ================================================================================
|
||||
MODE_MAPPING = {
|
||||
"STABILIZE": 0,
|
||||
"ACRO": 1,
|
||||
"ALT_HOLD": 2,
|
||||
"AUTO": 3,
|
||||
"GUIDED": 4,
|
||||
"LOITER": 5,
|
||||
"RTL": 6,
|
||||
"CIRCLE": 7,
|
||||
"POSITION": 8,
|
||||
"LAND": 9,
|
||||
"OF_LOITER": 10,
|
||||
"DRIFT": 11,
|
||||
"SPORT": 13,
|
||||
"FLIP": 14,
|
||||
"AUTOTUNE": 15,
|
||||
"POSHOLD": 16,
|
||||
"BRAKE": 17,
|
||||
"THROW": 18,
|
||||
"AVOID_ADSB": 19,
|
||||
"GUIDED_NOGPS": 20,
|
||||
"SMART_RTL": 21,
|
||||
}
|
||||
# ================================================================================
|
||||
|
||||
async def set_mode(self, drone_id, mode_name):
|
||||
"""使用 CommandLongClient 切換無人機飛行模式(使用非阻塞的 async 方法)"""
|
||||
try:
|
||||
# 解析 drone_id 提取 sysid
|
||||
parts = drone_id.split('_')
|
||||
if len(parts) < 2:
|
||||
print(f"❌ [SET_MODE] 無效的 drone_id 格式: {drone_id}")
|
||||
return False
|
||||
sysid = int(parts[-1])
|
||||
|
||||
# 獲取模式對應的 custom_mode 值
|
||||
custom_mode = self.MODE_MAPPING.get(mode_name)
|
||||
if custom_mode is None:
|
||||
print(f"❌ [SET_MODE] 未知模式: {mode_name}")
|
||||
return False
|
||||
|
||||
print(f"\n📢 [SET_MODE] {drone_id} → {mode_name} (custom_mode={custom_mode})")
|
||||
|
||||
if not self.command_long_client:
|
||||
print(f"❌ [SET_MODE] CommandLongClient 未初始化")
|
||||
return False
|
||||
|
||||
# 直接調用 async 方法,無需線程池(避免嵌套執行器衝突)
|
||||
result = await self.command_long_client.change_mode_async(
|
||||
target_sysid=sysid,
|
||||
custom_mode=float(custom_mode),
|
||||
target_compid=0,
|
||||
base_mode=1.0,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
if result and result.success:
|
||||
print(f"✅ [SET_MODE] 模式切換成功")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ [SET_MODE] 模式切換失敗 (message={result.message if result else 'None'})")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ [SET_MODE] 例外錯誤: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
async def arm_drone(self, drone_id, arm):
|
||||
if drone_id not in self.arm_clients:
|
||||
return False
|
||||
|
||||
client = self.arm_clients[drone_id]
|
||||
if not client.wait_for_service(timeout_sec=1.0):
|
||||
return False
|
||||
|
||||
request = CommandBool.Request()
|
||||
request.value = arm
|
||||
|
||||
future = client.call_async(request)
|
||||
"""使用 CommandLongClient 執行 ARM/DISARM(使用非阻塞的 async 方法)"""
|
||||
try:
|
||||
response = await future
|
||||
return response.success
|
||||
# 解析 drone_id 提取 sysid
|
||||
parts = drone_id.split('_')
|
||||
if len(parts) < 2:
|
||||
print(f"❌ [ARM] 無效的 drone_id 格式: {drone_id}")
|
||||
return False
|
||||
sysid = int(parts[-1])
|
||||
|
||||
action_name = "解鎖" if arm else "上鎖"
|
||||
print(f"\n📢 [ARM] {drone_id} → {action_name}")
|
||||
|
||||
if not self.command_long_client:
|
||||
print(f"❌ [ARM] CommandLongClient 未初始化")
|
||||
return False
|
||||
|
||||
# 直接調用 async 方法,無需線程池(避免嵌套執行器衝突)
|
||||
result = await self.command_long_client.arm_disarm_async(
|
||||
target_sysid=sysid,
|
||||
arm=arm,
|
||||
target_compid=0,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
if result and result.success:
|
||||
print(f"✅ [ARM] {action_name}成功")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ [ARM] {action_name}失敗 (message={result.message if result else 'None'})")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.get_logger().error(f'Arm service call failed: {e}')
|
||||
print(f"❌ [ARM] 例外錯誤: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
async def takeoff_drone(self, drone_id, altitude=10.0):
|
||||
if drone_id not in self.takeoff_clients:
|
||||
return False
|
||||
|
||||
client = self.takeoff_clients[drone_id]
|
||||
if not client.wait_for_service(timeout_sec=1.0):
|
||||
return False
|
||||
|
||||
request = CommandTOL.Request()
|
||||
request.altitude = altitude
|
||||
request.min_pitch = 0.0
|
||||
request.yaw = 0.0
|
||||
|
||||
future = client.call_async(request)
|
||||
async def takeoff_drone(self, drone_id, altitude):
|
||||
"""使用 CommandLongClient 執行無人機起飛(使用非阻塞的 async 方法)"""
|
||||
try:
|
||||
response = await future
|
||||
return response.success
|
||||
# 解析 drone_id 提取 sysid
|
||||
parts = drone_id.split('_')
|
||||
if len(parts) < 2:
|
||||
print(f"❌ [TAKEOFF] 無效的 drone_id 格式: {drone_id}")
|
||||
return False
|
||||
sysid = int(parts[-1])
|
||||
|
||||
print(f"\n📢 [TAKEOFF] {drone_id} → 起飛 (高度={altitude}m)")
|
||||
|
||||
if not self.command_long_client:
|
||||
print(f"❌ [TAKEOFF] CommandLongClient 未初始化")
|
||||
return False
|
||||
|
||||
# 直接調用 async 方法,無需線程池(避免嵌套執行器衝突)
|
||||
result = await self.command_long_client.takeoff_async(
|
||||
target_sysid=sysid,
|
||||
altitude_m=float(altitude),
|
||||
target_compid=0,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
if result and result.success:
|
||||
print(f"✅ [TAKEOFF] 起飛成功")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ [TAKEOFF] 起飛失敗 (message={result.message if result else 'None'})")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.get_logger().error(f'Takeoff service call failed: {e}')
|
||||
print(f"❌ [TAKEOFF] 例外錯誤: {e}")
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def send_setpoint(self, drone_id, x, y, z):
|
||||
|
||||
+485
-35
@@ -12,6 +12,7 @@ import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
import traceback
|
||||
|
||||
# 導入分離的類別
|
||||
from communication import DroneMonitor, UDPMavlinkReceiver, WebSocketMavlinkReceiver
|
||||
@@ -32,15 +33,18 @@ from mission_group import (
|
||||
# ================================================================================
|
||||
|
||||
class ControlStationUI(QMainWindow):
|
||||
VERSION = '1.0.1'
|
||||
VERSION = '2.0.6'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle(f'GCS v{self.VERSION}')
|
||||
self.resize(1400, 900)
|
||||
|
||||
# 初始化ROS2
|
||||
rclpy.init()
|
||||
# 初始化消息隊列(用於線程安全的 GUI 更新)
|
||||
import queue
|
||||
self.message_queue = queue.Queue()
|
||||
|
||||
# 初始化ROS2 Monitor(ROS2 本身在 main() 中已初始化)
|
||||
self.monitor = DroneMonitor()
|
||||
self.monitor.signals.update_signal.connect(self.update_ui)
|
||||
|
||||
@@ -48,16 +52,31 @@ class ControlStationUI(QMainWindow):
|
||||
self.executor = rclpy.executors.SingleThreadedExecutor()
|
||||
self.executor.add_node(self.monitor)
|
||||
|
||||
# 添加 CommandLongClient 到執行器(這樣它的回調才能被處理)
|
||||
if self.monitor.command_long_client:
|
||||
self.executor.add_node(self.monitor.command_long_client)
|
||||
|
||||
# 定时处理ROS事件
|
||||
self.timer = QTimer()
|
||||
self.timer.timeout.connect(self.spin_ros)
|
||||
self.timer.start(10)
|
||||
|
||||
# 驅動 asyncio 事件循環的定時器(新增 - 關鍵!)
|
||||
# 這允許異步任務(如 arm_drone)能夠執行
|
||||
self.asyncio_timer = QTimer()
|
||||
self.asyncio_timer.timeout.connect(self._spin_asyncio)
|
||||
self.asyncio_timer.start(10) # 每 10ms 驅動一次 asyncio
|
||||
|
||||
# 初始化 panel 和 map 更新(10Hz)
|
||||
self.panel_map_timer = QTimer()
|
||||
self.panel_map_timer.timeout.connect(self._update_panel_and_map)
|
||||
self.panel_map_timer.start(100) # 10Hz
|
||||
|
||||
# 消息隊列處理定時器(來自線程的 GUI 更新)
|
||||
self.msg_queue_timer = QTimer()
|
||||
self.msg_queue_timer.timeout.connect(self._process_message_queue)
|
||||
self.msg_queue_timer.start(50) # 每 50ms 檢查一次
|
||||
|
||||
# 快取消息數據,以便在沒有新消息時使用上一次的值
|
||||
self._message_cache = {}
|
||||
|
||||
@@ -164,20 +183,29 @@ class ControlStationUI(QMainWindow):
|
||||
|
||||
# ========== 任務群組 Tab ==========
|
||||
group_header = QHBoxLayout()
|
||||
|
||||
# 標題 + 收起/展開按鈕
|
||||
title_layout = QHBoxLayout()
|
||||
group_title = QLabel("任務群組")
|
||||
group_title.setStyleSheet(
|
||||
"color: #DDD; font-size: 14px; font-weight: bold; padding: 2px;")
|
||||
group_header.addWidget(group_title)
|
||||
group_header.addStretch()
|
||||
title_layout.addWidget(group_title)
|
||||
|
||||
add_group_btn = QPushButton("+ 新增群組")
|
||||
add_group_btn.setStyleSheet("""
|
||||
QPushButton { background-color: #4A9EFF; color: white; border: none;
|
||||
padding: 5px 12px; border-radius: 4px; font-size: 12px; font-weight: bold; }
|
||||
QPushButton:hover { background-color: #3A8EEF; }
|
||||
# 收起/展開按鈕
|
||||
self.toggle_group_btn = QPushButton("▼")
|
||||
self.toggle_group_btn.setStyleSheet("""
|
||||
QPushButton { background-color: #555; color: white; border: none;
|
||||
padding: 3px 8px; border-radius: 3px; font-size: 12px; font-weight: bold;
|
||||
min-width: 30px; max-width: 30px; }
|
||||
QPushButton:hover { background-color: #666; }
|
||||
""")
|
||||
add_group_btn.clicked.connect(self._add_mission_group)
|
||||
group_header.addWidget(add_group_btn)
|
||||
self.toggle_group_btn.setToolTip("收起/展開任務群組")
|
||||
self.toggle_group_btn.clicked.connect(self._toggle_group_panel)
|
||||
title_layout.addWidget(self.toggle_group_btn)
|
||||
title_layout.addStretch()
|
||||
|
||||
group_header.addLayout(title_layout)
|
||||
group_header.addStretch()
|
||||
|
||||
clear_traj_btn = QPushButton("清除軌跡")
|
||||
clear_traj_btn.setStyleSheet("""
|
||||
@@ -205,6 +233,9 @@ class ControlStationUI(QMainWindow):
|
||||
self.group_tab_widget.currentChanged.connect(self._on_group_tab_changed)
|
||||
right_layout.addWidget(self.group_tab_widget)
|
||||
|
||||
# 🌟 新增:保存群組面板的展開狀態
|
||||
self.group_panel_expanded = True
|
||||
|
||||
# 預設建立 Group A
|
||||
self._add_mission_group()
|
||||
|
||||
@@ -353,6 +384,8 @@ class ControlStationUI(QMainWindow):
|
||||
status_label.setToolTip("運行中")
|
||||
self.statusBar().showMessage(f"已啟動 Serial 連接: {conn['port']}", 3000)
|
||||
except Exception as e:
|
||||
print(f"❌ Serial 連接啟動失敗: {str(e)}")
|
||||
traceback.print_exc()
|
||||
self.statusBar().showMessage(f"啟動 Serial 連接失敗: {str(e)}", 5000)
|
||||
|
||||
def remove_serial_connection(self, conn, panel):
|
||||
@@ -377,22 +410,39 @@ class ControlStationUI(QMainWindow):
|
||||
# ================================================================================
|
||||
|
||||
def handle_mode_change(self, drone_id):
|
||||
print(f"\n📢 [GUI] handle_mode_change 被调用")
|
||||
print(f" drone_id: {drone_id}")
|
||||
|
||||
# 從 active group 的 mode_combo 讀取模式
|
||||
group = self._get_active_group()
|
||||
if group:
|
||||
panel = self.group_panels.get(group.group_id)
|
||||
mode = panel.mode_combo.currentText() if panel else "GUIDED"
|
||||
print(f" 从群组读取模式: {mode}")
|
||||
else:
|
||||
mode = "GUIDED"
|
||||
print(f" 没有活跃群组,使用默认模式: {mode}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
future = self.monitor.set_mode(drone_id, mode)
|
||||
loop.create_task(self.handle_service_response(future, f"切換模式 {mode} {drone_id}"))
|
||||
|
||||
def handle_arm(self, drone_id):
|
||||
print(f"\n📢 [GUI] handle_arm 被調用")
|
||||
print(f" drone_id: {drone_id}")
|
||||
loop = asyncio.get_event_loop()
|
||||
print(f" 獲得事件循環: {loop}")
|
||||
arm_state = not self.monitor.get_arm_state(drone_id)
|
||||
future = self.monitor.arm_drone(drone_id, arm_state)
|
||||
loop.create_task(self.handle_service_response(future, f"{'解鎖' if arm_state else '上鎖'} {drone_id}"))
|
||||
print(f" 當前鎖定狀態: {not arm_state}, 目標狀態: {arm_state}")
|
||||
print(f" 準備調用 arm_drone(drone_id={drone_id}, arm={arm_state})")
|
||||
coro = self.monitor.arm_drone(drone_id, arm_state)
|
||||
print(f" arm_drone() 返回類型: {type(coro)}")
|
||||
print(f" coroutine 對象: {coro}")
|
||||
action_text = f"{'解鎖' if arm_state else '上鎖'} {drone_id}"
|
||||
print(f" 準備提交到事件循環: {action_text}")
|
||||
task = loop.create_task(self.handle_service_response(coro, action_text))
|
||||
print(f" task 已創建: {task}")
|
||||
print(f" 已提交到事件循環\n")
|
||||
|
||||
def handle_takeoff(self, drone_id):
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -425,20 +475,52 @@ class ControlStationUI(QMainWindow):
|
||||
self.statusBar().showMessage("座標格式錯誤", 3000)
|
||||
|
||||
async def handle_service_response(self, future, action):
|
||||
print(f"\n📥 [GUI] handle_service_response 開始處理: {action}")
|
||||
print(f" Future/Coroutine 類型: {type(future)}")
|
||||
print(f" Future/Coroutine 對象: {future}")
|
||||
try:
|
||||
print(f" ├─ 等待 future/coroutine 完成...")
|
||||
print(f" └─ (這是一個阻塞等待,直到服務返回)\n")
|
||||
result = await future
|
||||
print(f"\n ✓ Future/Coroutine 完成,接收到返回值!")
|
||||
print(f" ├─ 返回值類型: {type(result)}")
|
||||
print(f" ├─ 返回值內容: {result}")
|
||||
print(f" ├─ 返回值 == True: {result == True}")
|
||||
print(f" ├─ 返回值 is True: {result is True}")
|
||||
print(f" └─ bool(返回值): {bool(result)}")
|
||||
|
||||
# 詳細檢查 result 結構(用於調試 fc_network 回傳值)
|
||||
if hasattr(result, '__dict__'):
|
||||
print(f" └─ 返回值屬性: {result.__dict__}")
|
||||
|
||||
if result:
|
||||
print(f"\n✅ {action} 成功 (result={result})")
|
||||
self.statusBar().showMessage(f"{action} 成功", 3000)
|
||||
else:
|
||||
print(f"\n❌ {action} 失敗 (result={result})")
|
||||
self.statusBar().showMessage(f"{action} 失敗", 3000)
|
||||
except asyncio.CancelledError:
|
||||
print(f"⚠️ {action} 被取消")
|
||||
except Exception as e:
|
||||
print(f"❌ {action} 錯誤: {str(e)}")
|
||||
traceback.print_exc()
|
||||
self.statusBar().showMessage(f"{action} 錯誤: {str(e)}", 3000)
|
||||
|
||||
def handle_arm_selected(self):
|
||||
print(f"\n📢 [GUI] handle_arm_selected 被調用")
|
||||
selected = list(self.monitor.selected_drones)
|
||||
print(f" 已選擇的無人機: {selected}")
|
||||
loop = asyncio.get_event_loop()
|
||||
for drone_id in self.monitor.selected_drones:
|
||||
future = self.monitor.arm_drone(drone_id, True)
|
||||
loop.create_task(self.handle_service_response(future, f"批次解鎖 {drone_id}"))
|
||||
for drone_id in selected:
|
||||
print(f" 準備批次解鎖: {drone_id}")
|
||||
coro = self.monitor.arm_drone(drone_id, True)
|
||||
print(f" arm_drone 返回: {coro}")
|
||||
# 使用 run_coroutine_threadsafe() 正確地在事件循環中運行
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.handle_service_response(coro, f"批次解鎖 {drone_id}"),
|
||||
loop
|
||||
)
|
||||
print(f" handle_arm_selected 完成\n")
|
||||
|
||||
def handle_takeoff_selected(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -456,6 +538,23 @@ class ControlStationUI(QMainWindow):
|
||||
self._group_counter += 1
|
||||
return gid
|
||||
|
||||
def _toggle_group_panel(self):
|
||||
"""🌟 收起/展開任務群組面板"""
|
||||
if self.group_panel_expanded:
|
||||
# 收起
|
||||
self.group_tab_widget.setFixedHeight(0)
|
||||
self.group_tab_widget.hide()
|
||||
self.toggle_group_btn.setText("▶")
|
||||
self.toggle_group_btn.setToolTip("展開任務群組")
|
||||
self.group_panel_expanded = False
|
||||
else:
|
||||
# 展開
|
||||
self.group_tab_widget.setFixedHeight(150)
|
||||
self.group_tab_widget.show()
|
||||
self.toggle_group_btn.setText("▼")
|
||||
self.toggle_group_btn.setToolTip("收起任務群組")
|
||||
self.group_panel_expanded = True
|
||||
|
||||
def _add_mission_group(self):
|
||||
"""新增一個任務群組"""
|
||||
gid = self._next_group_id()
|
||||
@@ -475,6 +574,8 @@ class ControlStationUI(QMainWindow):
|
||||
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)
|
||||
panel.add_group_requested.connect(self._add_mission_group)
|
||||
panel.delete_group_requested.connect(self._handle_delete_group)
|
||||
self.group_panels[gid] = panel
|
||||
|
||||
# 用帶顏色的 tab 標題
|
||||
@@ -538,6 +639,19 @@ class ControlStationUI(QMainWindow):
|
||||
if panel:
|
||||
panel.update_drone_list()
|
||||
panel.update_status()
|
||||
|
||||
# 同步更新左側面板的 checkbox 狀態
|
||||
self.monitor.selected_drones = group.drone_ids.copy()
|
||||
for drone_id in all_ids:
|
||||
if drone_id in self.drones:
|
||||
checkbox = self.drones[drone_id].get_checkbox()
|
||||
if checkbox:
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(drone_id in group.drone_ids)
|
||||
checkbox.blockSignals(False)
|
||||
# 更新 socket 群組的 checkbox 狀態
|
||||
self.update_group_checkbox_state(self.get_socket_id(drone_id))
|
||||
|
||||
self.statusBar().showMessage(
|
||||
f"Group {group_id}: 已分配 {len(group.drone_ids)} 台無人機", 3000)
|
||||
|
||||
@@ -611,23 +725,86 @@ class ControlStationUI(QMainWindow):
|
||||
|
||||
def _handle_group_mode_change(self, group_id, mode):
|
||||
"""切換群組內所有無人機的飛行模式"""
|
||||
print(f"\n📢 [GUI] _handle_group_mode_change 被调用", flush=True)
|
||||
print(f" group_id: {group_id}, mode: {mode}", flush=True)
|
||||
|
||||
group = self.mission_groups.get(group_id)
|
||||
if not group:
|
||||
print(f"❌ 找不到群組: {group_id}", flush=True)
|
||||
return
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
if not group.drone_ids:
|
||||
print(f"⚠️ 群組中沒有無人機", flush=True)
|
||||
self.statusBar().showMessage(f"群組 {group_id} 中沒有無人機", 3000)
|
||||
return
|
||||
|
||||
print(f" 準備為 {len(group.drone_ids)} 台無人機切換模式...", flush=True)
|
||||
self.statusBar().showMessage(f"正在切換模式...", 1000)
|
||||
|
||||
# 使用 asyncio 執行(通過事件循環)
|
||||
async def do_mode_changes_async():
|
||||
print(f"\n 【異步任務】開始執行模式切換", flush=True)
|
||||
|
||||
for drone_id in group.drone_ids:
|
||||
future = self.monitor.set_mode(drone_id, mode)
|
||||
loop.create_task(self.handle_service_response(future, f"{drone_id} 切換 {mode}"))
|
||||
print(f"\n 【切換】{drone_id} → {mode}", flush=True)
|
||||
try:
|
||||
result = await self.monitor.set_mode(drone_id, mode)
|
||||
|
||||
if result:
|
||||
msg = f"✅ {drone_id} 切換成功"
|
||||
print(f" {msg}", flush=True)
|
||||
self.message_queue.put((msg, 2000))
|
||||
else:
|
||||
msg = f"❌ {drone_id} 切換失敗"
|
||||
print(f" {msg}", flush=True)
|
||||
self.message_queue.put((msg, 2000))
|
||||
|
||||
except Exception as e:
|
||||
msg = f"❌ {drone_id} 錯誤: {str(e)}"
|
||||
print(f" {msg}", flush=True)
|
||||
traceback.print_exc()
|
||||
self.message_queue.put((msg, 2000))
|
||||
|
||||
print(f"\n 【完成】模式切換任務結束\n", flush=True)
|
||||
|
||||
# 通過事件循環提交異步任務
|
||||
print(f" 【排隊】將任務提交至事件循環", flush=True)
|
||||
loop = asyncio.get_event_loop()
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
do_mode_changes_async(),
|
||||
loop
|
||||
)
|
||||
|
||||
def _handle_group_arm(self, group_id):
|
||||
"""解鎖群組內所有無人機"""
|
||||
print(f"\n📢 [GUI] _handle_group_arm 被調用")
|
||||
print(f" 群組 ID: {group_id}")
|
||||
group = self.mission_groups.get(group_id)
|
||||
print(f" 群組存在: {group is not None}")
|
||||
if not group:
|
||||
print(f" ⚠️ 群組不存在,返回\n")
|
||||
return
|
||||
print(f" 群組內無人機: {group.drone_ids}")
|
||||
loop = asyncio.get_event_loop()
|
||||
print(f" 事件循環: {loop}")
|
||||
|
||||
for drone_id in group.drone_ids:
|
||||
future = self.monitor.arm_drone(drone_id, True)
|
||||
loop.create_task(self.handle_service_response(future, f"解鎖 {drone_id}"))
|
||||
print(f"\n ┌─ 處理無人機: {drone_id}")
|
||||
print(f" ├─ 準備調用 arm_drone(drone_id={drone_id}, arm=True)")
|
||||
coro = self.monitor.arm_drone(drone_id, True)
|
||||
print(f" ├─ arm_drone 返回類型: {type(coro)}")
|
||||
action_text = f"解鎖 {drone_id}"
|
||||
print(f" ├─ 準備提交到事件循環: {action_text}")
|
||||
# 使用 run_coroutine_threadsafe() 正確地在事件循環中運行 coroutine
|
||||
# 這是 Qt + asyncio 整合的正確方式
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.handle_service_response(coro, action_text),
|
||||
loop
|
||||
)
|
||||
print(f" ├─ Future 已創建: {future}")
|
||||
print(f" └─ Future 將在事件循環中執行")
|
||||
|
||||
print(f"\n _handle_group_arm 完成(coroutine 已提交至事件循環)\n")
|
||||
|
||||
def _handle_group_takeoff(self, group_id, altitude):
|
||||
"""群組內所有無人機起飛"""
|
||||
@@ -663,23 +840,59 @@ class ControlStationUI(QMainWindow):
|
||||
if panel:
|
||||
panel.update_drone_list()
|
||||
panel.update_status()
|
||||
|
||||
# 同步更新左側面板的 checkbox 狀態
|
||||
self.monitor.selected_drones = group.drone_ids.copy()
|
||||
for drone_id in self.drones.keys():
|
||||
checkbox = self.drones[drone_id].get_checkbox()
|
||||
if checkbox:
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(drone_id in group.drone_ids)
|
||||
checkbox.blockSignals(False)
|
||||
self.update_group_checkbox_state(self.get_socket_id(drone_id))
|
||||
|
||||
self.statusBar().showMessage(
|
||||
f"Group {group_id}: 框選分配 {len(valid)} 台無人機", 3000)
|
||||
|
||||
def _handle_select_all_for_group(self, group_id):
|
||||
"""全選所有可用無人機,直接分配到該群組"""
|
||||
"""全選/取消全選 - Toggle 邏輯"""
|
||||
group = self.mission_groups.get(group_id)
|
||||
if not group:
|
||||
return
|
||||
|
||||
other = self._get_other_assigned(group_id)
|
||||
available = {did for did in self.drones.keys() if did not in other}
|
||||
|
||||
# Toggle 邏輯:如果已全選,則清空;否則全選
|
||||
if group.drone_ids == available:
|
||||
# 已全選 → 清空
|
||||
group.drone_ids = set()
|
||||
self.monitor.selected_drones.clear()
|
||||
msg_status = "已取消全選"
|
||||
else:
|
||||
# 未全選 → 全選
|
||||
group.drone_ids = available
|
||||
self.monitor.selected_drones = group.drone_ids.copy()
|
||||
msg_status = f"全選分配 {len(available)} 台無人機"
|
||||
|
||||
panel = self.group_panels.get(group_id)
|
||||
if panel:
|
||||
panel.update_drone_list()
|
||||
panel.update_status()
|
||||
# 更新按鈕文本
|
||||
panel.set_all_select_state(group.drone_ids == available)
|
||||
|
||||
# 同步更新左側面板的 checkbox 狀態
|
||||
for drone_id in self.drones.keys():
|
||||
checkbox = self.drones[drone_id].get_checkbox()
|
||||
if checkbox:
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(drone_id in group.drone_ids)
|
||||
checkbox.blockSignals(False)
|
||||
self.update_group_checkbox_state(self.get_socket_id(drone_id))
|
||||
|
||||
self.statusBar().showMessage(
|
||||
f"Group {group_id}: 全選分配 {len(available)} 台無人機", 3000)
|
||||
f"Group {group_id}: {msg_status}", 3000)
|
||||
|
||||
def _handle_clear_group(self, group_id):
|
||||
"""清除群組的無人機分配"""
|
||||
@@ -696,9 +909,59 @@ class ControlStationUI(QMainWindow):
|
||||
panel.update_drone_list()
|
||||
panel.update_status()
|
||||
panel.clear_mission_info()
|
||||
|
||||
# 同步更新左側面板的 checkbox 狀態(全部取消勾選)
|
||||
self.monitor.selected_drones.clear()
|
||||
for drone_id in self.drones.keys():
|
||||
checkbox = self.drones[drone_id].get_checkbox()
|
||||
if checkbox:
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(False)
|
||||
checkbox.blockSignals(False)
|
||||
self.update_group_checkbox_state(self.get_socket_id(drone_id))
|
||||
|
||||
self.statusBar().showMessage(
|
||||
f"Group {group_id}: 已清除分組", 3000)
|
||||
|
||||
def _handle_delete_group(self, group_id):
|
||||
"""刪除一個任務群組"""
|
||||
if group_id not in self.mission_groups:
|
||||
self.statusBar().showMessage(f"Group {group_id} 不存在", 3000)
|
||||
return
|
||||
|
||||
# 停止群組的執行(如果有)
|
||||
group = self.mission_groups[group_id]
|
||||
if group.executor:
|
||||
group.executor.stop()
|
||||
|
||||
# 移除地圖上的任務計畫
|
||||
self.drone_map.clear_mission_plan_for_group(group_id)
|
||||
|
||||
# 移除 tab
|
||||
for i in range(self.group_tab_widget.count()):
|
||||
if f"Group {group_id}" in self.group_tab_widget.tabText(i):
|
||||
self.group_tab_widget.removeTab(i)
|
||||
break
|
||||
|
||||
# 移除資料
|
||||
del self.mission_groups[group_id]
|
||||
if group_id in self.group_panels:
|
||||
del self.group_panels[group_id]
|
||||
|
||||
# 更新 active group
|
||||
if self.active_group_id == group_id:
|
||||
if self.group_tab_widget.count() > 0:
|
||||
self.group_tab_widget.setCurrentIndex(0)
|
||||
# 更新 active_group_id 為當前 tab 的群組
|
||||
for gid, panel in self.group_panels.items():
|
||||
if panel == self.group_tab_widget.currentWidget().widget():
|
||||
self.active_group_id = gid
|
||||
break
|
||||
else:
|
||||
self.active_group_id = None
|
||||
|
||||
self.statusBar().showMessage(f"已刪除 Group {group_id}", 3000)
|
||||
|
||||
def _on_group_mission_completed(self, group_id):
|
||||
"""群組任務完成回呼"""
|
||||
panel = self.group_panels.get(group_id)
|
||||
@@ -739,24 +1002,112 @@ class ControlStationUI(QMainWindow):
|
||||
# ================================================================================
|
||||
|
||||
def handle_group_selection(self, socket_id, state):
|
||||
"""處理 socket 群組 checkbox 的勾選/取消勾選
|
||||
|
||||
這個方法在用戶點擊 socket 群組的 checkbox 時被調用。
|
||||
需要同時更新:
|
||||
1. 該 socket 下所有無人機的 checkbox
|
||||
2. self.monitor.selected_drones(用於控制面板同步)
|
||||
3. 右侧活躍群組的無人機分配(新增)
|
||||
|
||||
參數:
|
||||
socket_id: socket ID (str)
|
||||
state: Qt.CheckState 的整數值 (0=Unchecked, 1=PartiallyChecked, 2=Checked)
|
||||
"""
|
||||
print(f"\n📢 [GUI] handle_group_selection 被調用", flush=True)
|
||||
print(f" socket_id: {socket_id}, state: {state}", flush=True)
|
||||
print(f" state 類型: {type(state)}", flush=True)
|
||||
|
||||
# 獲取該 socket 下所有無人機
|
||||
group_drones = [did for did in self.drones.keys() if self.get_socket_id(did) == socket_id]
|
||||
is_checked = state == Qt.CheckState.Checked.value
|
||||
print(f" 該 socket 下的無人機: {group_drones}", flush=True)
|
||||
|
||||
# 判斷是否勾選(只有 state == 2 時才是 Checked)
|
||||
is_checked = (state == 2) # Qt.CheckState.Checked.value == 2
|
||||
print(f" is_checked: {is_checked}", flush=True)
|
||||
|
||||
# 更新該 socket 下所有無人機的 checkbox 狀態
|
||||
for drone_id in group_drones:
|
||||
checkbox = self.drones[drone_id].get_checkbox()
|
||||
if checkbox:
|
||||
print(f" └─ 更新 {drone_id}: setChecked({is_checked})", flush=True)
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(is_checked)
|
||||
checkbox.blockSignals(False)
|
||||
if is_checked: self.monitor.selected_drones.add(drone_id)
|
||||
else: self.monitor.selected_drones.discard(drone_id)
|
||||
|
||||
# 同時更新 monitor.selected_drones 以同步控制面板
|
||||
if is_checked:
|
||||
self.monitor.selected_drones.add(drone_id)
|
||||
else:
|
||||
self.monitor.selected_drones.discard(drone_id)
|
||||
|
||||
# 👇 新增:同步更新右侧活躍群組的無人機分配
|
||||
if self.active_group_id:
|
||||
group = self.mission_groups.get(self.active_group_id)
|
||||
panel = self.group_panels.get(self.active_group_id)
|
||||
if group and panel:
|
||||
print(f" ├─ 同步右侧群組 {self.active_group_id}", flush=True)
|
||||
if is_checked:
|
||||
# 勾選時:將該 socket 下的無人機添加到活躍群組
|
||||
for drone_id in group_drones:
|
||||
group.drone_ids.add(drone_id)
|
||||
print(f" │ └─ 添加到群組: {group_drones}", flush=True)
|
||||
else:
|
||||
# 取消勾選時:從活躍群組移除該 socket 下的無人機
|
||||
for drone_id in group_drones:
|
||||
group.drone_ids.discard(drone_id)
|
||||
print(f" │ └─ 從群組移除: {group_drones}", flush=True)
|
||||
|
||||
# 更新右側群組面板的顯示
|
||||
panel.update_drone_list()
|
||||
panel.update_status()
|
||||
print(f" │ └─ 已更新右侧群組面板", flush=True)
|
||||
|
||||
print(f" 最終 selected_drones: {self.monitor.selected_drones}", flush=True)
|
||||
print(f"✓ handle_group_selection 完成\n", flush=True)
|
||||
|
||||
def handle_drone_selection(self, drone_id, state):
|
||||
if state == Qt.CheckState.Checked.value:
|
||||
is_checked = state == Qt.CheckState.Checked.value
|
||||
if is_checked:
|
||||
self.monitor.selected_drones.add(drone_id)
|
||||
else:
|
||||
self.monitor.selected_drones.discard(drone_id)
|
||||
self.update_group_checkbox_state(self.get_socket_id(drone_id))
|
||||
|
||||
# 同步更新任務群組的無人機分配狀態
|
||||
# 遍歷所有任務群組,更新已分配的無人機列表顯示
|
||||
if not is_checked:
|
||||
# 取消勾選時:從所有包含該無人機的群組中移除
|
||||
for group_id, group in self.mission_groups.items():
|
||||
if drone_id in group.drone_ids:
|
||||
group.drone_ids.discard(drone_id)
|
||||
panel = self.group_panels.get(group_id)
|
||||
if panel:
|
||||
panel.update_drone_list()
|
||||
panel.update_status()
|
||||
# 更新全選按鈕狀態
|
||||
other = self._get_other_assigned(group_id)
|
||||
available = {did for did in self.drones.keys() if did not in other}
|
||||
panel.set_all_select_state(group.drone_ids == available)
|
||||
else:
|
||||
# 勾選時:檢查該無人機是否已分配給其他群組,若未分配則添加到當前活躍群組
|
||||
is_already_assigned = any(
|
||||
drone_id in group.drone_ids
|
||||
for group in self.mission_groups.values()
|
||||
)
|
||||
if not is_already_assigned and self.active_group_id:
|
||||
# 無人機未被分配給任何群組,可以添加到當前活躍群組
|
||||
group = self.mission_groups.get(self.active_group_id)
|
||||
panel = self.group_panels.get(self.active_group_id)
|
||||
if group and panel:
|
||||
group.drone_ids.add(drone_id)
|
||||
panel.update_drone_list()
|
||||
panel.update_status()
|
||||
# 更新全選按鈕狀態
|
||||
other = self._get_other_assigned(self.active_group_id)
|
||||
available = {did for did in self.drones.keys() if did not in other}
|
||||
panel.set_all_select_state(group.drone_ids == available)
|
||||
|
||||
def update_group_checkbox_state(self, socket_id):
|
||||
group_drones = [did for did in self.drones.keys() if self.get_socket_id(did) == socket_id]
|
||||
if not group_drones: return
|
||||
@@ -890,7 +1241,6 @@ class ControlStationUI(QMainWindow):
|
||||
)
|
||||
except Exception as e:
|
||||
self.statusBar().showMessage(f"❌ Group {group.group_id}: 規劃失敗: {str(e)}", 5000)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# ================================================================================
|
||||
@@ -962,7 +1312,6 @@ class ControlStationUI(QMainWindow):
|
||||
)
|
||||
except Exception as e:
|
||||
self.statusBar().showMessage(f"❌ Group {group.group_id}: Grid Sweep 規劃失敗: {str(e)}", 5000)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# ================================================================================
|
||||
@@ -1042,7 +1391,6 @@ class ControlStationUI(QMainWindow):
|
||||
)
|
||||
except Exception as e:
|
||||
self.statusBar().showMessage(f"❌ Group {group.group_id}: 跟隨模式規劃失敗: {str(e)}", 5000)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# ================================================================================
|
||||
@@ -1180,7 +1528,6 @@ class ControlStationUI(QMainWindow):
|
||||
self._map_update_count += 1
|
||||
now = time.time()
|
||||
if now - self._map_update_time >= 1.0:
|
||||
print(f"[Panel/Map Update] {self._map_update_count} Hz")
|
||||
self._map_update_time = now
|
||||
self._map_update_count = 0
|
||||
|
||||
@@ -1314,16 +1661,57 @@ class ControlStationUI(QMainWindow):
|
||||
|
||||
|
||||
|
||||
def _process_message_queue(self):
|
||||
"""處理來自後台線程的消息隊列(更新 GUI 狀態欄)"""
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
message, duration = self.message_queue.get_nowait()
|
||||
self.statusBar().showMessage(message, duration)
|
||||
except:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"❌ 消息隊列處理錯誤: {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
|
||||
def _spin_asyncio(self):
|
||||
"""驅動 asyncio 事件循環,允許異步任務執行
|
||||
|
||||
關鍵修復:asyncio 事件循環不會自動運行。
|
||||
這個定時器會定期執行事件循環,讓 run_coroutine_threadsafe() 提交的任務能夠執行。
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop and not loop.is_closed() and not loop.is_running():
|
||||
# 執行事件循環直到沒有掛起的任務或超時
|
||||
# 使用 run_until_complete() 配合一個快速返回的 coroutine
|
||||
loop.run_until_complete(asyncio.sleep(0))
|
||||
except Exception as e:
|
||||
# 靜默忽略任何錯誤,防止 Qt 定時器出現異常
|
||||
# 但仍然打印詳細的堆棧跟踪以便調試
|
||||
traceback.print_exc()
|
||||
|
||||
def spin_ros(self):
|
||||
try:
|
||||
# 仅在 ROS2 正常工作时才尝试 spin
|
||||
if rclpy.ok():
|
||||
self.executor.spin_once(timeout_sec=0.01)
|
||||
for (drone_id, msg_type), data in self.monitor.latest_data.items():
|
||||
self.monitor.signals.update_signal.emit(msg_type, drone_id, data)
|
||||
self.monitor.latest_data.clear()
|
||||
except RuntimeError as e:
|
||||
# ROS2 context 被破坏或不可用
|
||||
if "Context" in str(e) or "context" in str(e).lower():
|
||||
print(f"⚠️ ROS2 context 錯誤(忽略): {e}", flush=True)
|
||||
else:
|
||||
print(f"ROS spin error: {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
except Exception as e:
|
||||
print(f"ROS spin error: {e}")
|
||||
print(f"ROS spin error: {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
|
||||
def closeEvent(self, event):
|
||||
try:
|
||||
for group in self.mission_groups.values():
|
||||
if group.executor:
|
||||
group.executor.stop()
|
||||
@@ -1332,14 +1720,76 @@ class ControlStationUI(QMainWindow):
|
||||
receiver.stop()
|
||||
for receiver in self.monitor.ws_receivers:
|
||||
receiver.stop()
|
||||
# Clean up serial receivers
|
||||
for receiver in self.monitor.serial_receivers:
|
||||
receiver.stop()
|
||||
# Clean up CommandLongClient
|
||||
if self.monitor.command_long_client:
|
||||
try:
|
||||
self.monitor.command_long_client.destroy_node()
|
||||
except:
|
||||
pass
|
||||
self.monitor.destroy_node()
|
||||
self.executor.shutdown()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 清理資源時出錯: {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
|
||||
# 安全地 shutdown ROS2
|
||||
try:
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
except RuntimeError as e:
|
||||
print(f"⚠️ ROS2 shutdown 錯誤: {e}", flush=True)
|
||||
traceback.print_exc()
|
||||
|
||||
event.accept()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
def main():
|
||||
"""
|
||||
GUI 應用程式的主入口點
|
||||
|
||||
標準 ROS2 + Qt 架構:
|
||||
1. 在最外層/最前面只做一次 rclpy.init()
|
||||
2. 啟動 Qt 應用程式
|
||||
3. 在 finally 中做 rclpy.shutdown()
|
||||
|
||||
這樣可以確保所有 ROS2 相關操作都共享同一個初始化狀態
|
||||
"""
|
||||
# 第一步:在最外層只初始化一次 ROS2(終極防護)
|
||||
# 添加 rclpy.ok() 檢查,防止重複初始化導致 "Context.init() must only be called once" 錯誤
|
||||
print("🚀 [GUI 主程式] 檢查 ROS2 初始化狀態...", flush=True)
|
||||
if not rclpy.ok():
|
||||
print("🚀 [GUI 主程式] ROS2 未初始化,開始初始化...", flush=True)
|
||||
rclpy.init()
|
||||
print("✅ [GUI 主程式] ROS2 已全局初始化(由 GUI 主程式霸佔)", flush=True)
|
||||
else:
|
||||
print("ℹ️ [GUI 主程式] ROS2 已初始化,跳過重複初始化", flush=True)
|
||||
|
||||
try:
|
||||
# 第二步:啟動 Qt 應用程式
|
||||
print("🚀 [GUI 主程式] 啟動 Qt 應用程式...", flush=True)
|
||||
app = QApplication(sys.argv)
|
||||
station = ControlStationUI()
|
||||
station.show()
|
||||
app.exec()
|
||||
print("✓ [GUI 主程式] 應用程式已啟動", flush=True)
|
||||
|
||||
# 第三步:進入 Qt 事件循環(阻塞直到用戶關閉應用)
|
||||
print("🎯 [GUI 主程式] 進入主事件循環,等待用戶操作...", flush=True)
|
||||
sys.exit(app.exec())
|
||||
|
||||
finally:
|
||||
# 第四步:只有當 GUI 視窗被關閉時,才做 ROS2 cleanup(終極防護)
|
||||
# 這裡確保 ROS2 被正確且安全地關閉
|
||||
print("\n🛑 [GUI 主程式] 應用程式關閉,正在清理 ROS2 資源...", flush=True)
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
print("✓ [GUI 主程式] ROS2 已安全關閉", flush=True)
|
||||
else:
|
||||
print("ℹ️ [GUI 主程式] ROS2 已關閉或不可用,無需重複 shutdown", flush=True)
|
||||
print("✓ [GUI 主程式] 應用程式完全退出", flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+46
-22
@@ -146,6 +146,8 @@ class GroupPanel(QWidget):
|
||||
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;
|
||||
@@ -157,6 +159,8 @@ class GroupPanel(QWidget):
|
||||
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):
|
||||
@@ -296,7 +300,7 @@ class GroupPanel(QWidget):
|
||||
mid.addLayout(mid_body)
|
||||
|
||||
# ============================
|
||||
# 選取與分組(2x2 按鈕)
|
||||
# 選取與分組(3x2 按鈕)
|
||||
# ============================
|
||||
right = QVBoxLayout()
|
||||
right.setSpacing(3)
|
||||
@@ -310,9 +314,28 @@ class GroupPanel(QWidget):
|
||||
self.drone_list_label.setWordWrap(True)
|
||||
right.addWidget(self.drone_list_label)
|
||||
|
||||
# 2x2 按鈕網格
|
||||
# 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(
|
||||
@@ -321,22 +344,13 @@ class GroupPanel(QWidget):
|
||||
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))
|
||||
grid_r1.addWidget(assign_btn)
|
||||
grid_r1.addWidget(clear_btn)
|
||||
right.addLayout(grid_r1)
|
||||
|
||||
grid_r2 = QHBoxLayout()
|
||||
grid_r2.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(
|
||||
lambda: self.select_all_requested.emit(self.group.group_id))
|
||||
grid_r2.addWidget(box_btn)
|
||||
grid_r2.addWidget(all_btn)
|
||||
delete_group_btn = QPushButton("刪除群組")
|
||||
delete_group_btn.setStyleSheet(BTN.format(bg='#EF5350', fg='white', hover='#E53935'))
|
||||
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(delete_group_btn)
|
||||
right.addLayout(grid_r2)
|
||||
|
||||
right.addStretch()
|
||||
@@ -410,13 +424,15 @@ class GroupPanel(QWidget):
|
||||
self.type_combo.currentTextChanged.connect(self._update_param_visibility)
|
||||
|
||||
# ── 組裝四欄:控制指令 > 任務規劃 > 任務參數 > 選取與分組 ──
|
||||
cols.addLayout(left, 1)
|
||||
# 使用伸展因子 0 讓列根據內容自動調整寬度,而不是均等分配
|
||||
cols.addLayout(left, 0)
|
||||
cols.addWidget(self._make_sep())
|
||||
cols.addLayout(mid, 1)
|
||||
cols.addLayout(mid, 0)
|
||||
cols.addWidget(self._make_sep())
|
||||
cols.addLayout(param_col, 1)
|
||||
cols.addLayout(param_col, 0)
|
||||
cols.addWidget(self._make_sep())
|
||||
cols.addLayout(right, 1)
|
||||
cols.addLayout(right, 0)
|
||||
cols.addStretch() # 填充剩餘空間,使四列置左
|
||||
|
||||
layout.addLayout(cols)
|
||||
|
||||
@@ -451,6 +467,14 @@ class GroupPanel(QWidget):
|
||||
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 _update_param_visibility(self, _=None):
|
||||
"""根據當前任務類型,顯示/隱藏對應的參數列"""
|
||||
mission_type = self.type_combo.currentText()
|
||||
|
||||
@@ -6,7 +6,7 @@ class OverviewTable(QTableWidget):
|
||||
"""總覽表格,顯示所有無人機的狀態資訊"""
|
||||
|
||||
# 默認的資訊類型和映射
|
||||
DEFAULT_INFO_TYPES = ["模式", "ARM", "電壓", "經度", "緯度", "高度", "位置", "速度", "地速", "航向",
|
||||
DEFAULT_INFO_TYPES = ["模式", "ARM", "電壓", "經度", "緯度", "高度", "XY位置", "XY速度", "地速", "航向",
|
||||
"空速", "油門", "HUD ALT", "爬升率", "Roll", "Pitch", "Yaw", "丟包", "延遲"]
|
||||
|
||||
DEFAULT_INFO_TYPE_MAP = {
|
||||
|
||||
@@ -12,6 +12,7 @@ find_package(rosidl_default_generators REQUIRED)
|
||||
rosidl_generate_interfaces(${PROJECT_NAME}
|
||||
"srv/MavPing.srv"
|
||||
"srv/MavCommandLong.srv"
|
||||
"srv/MavPositionTargetGlobalInt.srv"
|
||||
)
|
||||
|
||||
ament_package()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Request
|
||||
uint8 target_sysid
|
||||
uint8 target_compid
|
||||
|
||||
uint32 time_boot_ms # ms since system boot, 一般用 0 讓飛控自己填
|
||||
uint8 coordinate_frame # MAV_FRAME_*
|
||||
uint16 type_mask # POSITION_TARGET_TYPEMASK bitmask
|
||||
int32 lat_int # 1e7 * latitude (deg)
|
||||
int32 lon_int # 1e7 * longitude (deg)
|
||||
float32 alt # altitude (m)
|
||||
float32 vx # X velocity (m/s)
|
||||
float32 vy # Y velocity (m/s)
|
||||
float32 vz # Z velocity (m/s)
|
||||
float32 afx # X acceleration or force (m/s^2)
|
||||
float32 afy # Y acceleration or force (m/s^2)
|
||||
float32 afz # Z acceleration or force (m/s^2)
|
||||
float32 yaw # yaw (rad)
|
||||
float32 yaw_rate # yaw rate (rad/s)
|
||||
float32 timeout_sec # 等待 POSITION_TARGET_GLOBAL_INT 回應的時間上限
|
||||
---
|
||||
# Response
|
||||
string message
|
||||
uint8 ack_result
|
||||
uint32 r_time_boot_ms
|
||||
uint16 r_type_mask
|
||||
int32 r_lat_int
|
||||
int32 r_lon_int
|
||||
float32 r_alt
|
||||
float32 r_vx
|
||||
float32 r_vy
|
||||
float32 r_vz
|
||||
float32 r_afx
|
||||
float32 r_afy
|
||||
float32 r_afz
|
||||
float32 r_yaw
|
||||
float32 r_yaw_rate
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
'''
|
||||
單獨出來定義回傳碼的意義
|
||||
若跟飛控韌體有正常的通訊 (無論是否被拒絕) 則以 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
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# 開發此專案的注意事項
|
||||
- 預設 autopilot 的 component id = 1
|
||||
- 不允許 system id 重複
|
||||
- 預設同一載具僅會使用相同的 socket
|
||||
- 增加一個固定數值監控然後要到 ros2 topic
|
||||
- mavlinkROS2Node.py 檔案內
|
||||
- PublishRateController.topic_intervals 建立
|
||||
@@ -126,6 +127,7 @@
|
||||
- *cls.mavlinkObjects* 資料結構 { socket_id(序號) : mavlink_object(物件實例) }
|
||||
- *self.mavlink_socket* 從 pymavlink 繼承的socket物件
|
||||
- *self.state* 描述這個 socket 物件的狀態
|
||||
- *self.MAVLink* pymavlink 的一個裝置物件 模擬自己是某一個裝置 用以對於該 mavlink bus 負責封包發送
|
||||
---
|
||||
- *process_data()* [async method] 核心方法
|
||||
- *remove_target_socket()* *add_target_socket()*
|
||||
@@ -170,6 +172,45 @@
|
||||
## mavlinkVehicleView.py
|
||||
這個檔案是作為載具的資訊暫存庫使用 會搭配 ROS2 的功能 再做利用
|
||||
|
||||
## mavlinkROS2Nodes.py
|
||||
這裡支持了所有 ROS2 框架的對應功能 包涵
|
||||
1. 將載具的串流更新的狀態更新到對應的 topic
|
||||
2. 將地面站與載具互動 mavlink microservice 包裝起來提供 ros2 service 互動介面
|
||||
|
||||
### **[Class]** fc_ros_manager
|
||||
MAVLink ROS2 節點管理器 管理兩個獨立的 Node
|
||||
會開一個執行緒 讓兩個 Node 都跑在裡面
|
||||
然後用 spin_once 保持 Node 的活性
|
||||
- *self.status_publisher* 物件實例
|
||||
- *self.command_service* 物件實例
|
||||
- *self.spin_thread* 執行緒
|
||||
---
|
||||
- *start()* 啟動自己
|
||||
- *stop()* 停下自己
|
||||
- *shutdown()* 關閉自己並清理
|
||||
|
||||
### **[Class]** VehicleStatusPublisher
|
||||
這整個組件都是自動的 目前沒有什麼需要 runtime 設定的地方
|
||||
- *self.fc_publishers* 儲存所有 publisher 的資料結構
|
||||
- *self.rate_controller* 儲存頻率參數的地方 也可以關掉某個 topic 的發佈
|
||||
---
|
||||
- *timer_callback()* 自動的抓出現有的 vehicle 中有的資訊 然後固定的將訊息丟到負責發佈的方法中
|
||||
- *_publish_vehicle_status()* 接 timer_callback 後去分配到各項發佈中
|
||||
- *_get_or_create_publisher()* 實際創建 topic 的地方
|
||||
- *_publish_XXX()* 各項發佈的方法
|
||||
|
||||
### **[Class]** MavlinkCommandService
|
||||
提供 ros2 service 讓 serial_object 去丟出 mavlink 封包
|
||||
然後會從回應封包中挑出期待的回應 再傳給 ros2 request
|
||||
其中 PendingEntry 是很關鍵的東西
|
||||
它的每個物件 代表需要等待的封包種類與必需包含的內容
|
||||
- *self._pending_by_sysid* 儲存 PendingEntry 的地方
|
||||
---
|
||||
- *return_router()* 檢查並消耗 return_packet_ring 然後將封包分配到 pending 去
|
||||
- *__init__()* 這邊登入要創建的 service 到 ros2 系統
|
||||
- *handle_XXX()* 這邊是實現 service 的具體方法
|
||||
|
||||
|
||||
# 開發記錄
|
||||
|
||||
## 已實現功能
|
||||
@@ -182,9 +223,9 @@
|
||||
7. 終端機介面控制
|
||||
8. 基礎載具流量觀測
|
||||
9. 載具狀態收集與彙整
|
||||
10. a. ros2 topic 應用開發介面
|
||||
10. a. ros2 topic 應用開發介面 (基礎框架)
|
||||
b. ros2 service 應用開發介面 (基礎框架)
|
||||
|
||||
### 待開發功能
|
||||
5-1. 建立 serial 連線 並可以對接收器下達AT指令
|
||||
5-2. 模組化 serial 連線機制 以利後期擴容其他模組
|
||||
10. a. ros2 應用開發介面
|
||||
@@ -29,7 +29,7 @@ from .utils import acquireSerial, acquirePort
|
||||
from .utils.acquirePort import find_available_port
|
||||
|
||||
logger = setup_logger(os.path.basename(__file__))
|
||||
VERSION_NO = "v0.60"
|
||||
PROJECT_VER = "v0.61"
|
||||
|
||||
class PanelState:
|
||||
def __init__(self):
|
||||
@@ -304,7 +304,7 @@ class ControlPanel:
|
||||
# help_line = start_line + len(current_menu.children) + 2
|
||||
help_line = height - 2
|
||||
stdscr.addstr(help_line, 2, "操作: ↑↓選擇 Enter確認 ←返回上層 →進入下層", curses.A_DIM)
|
||||
stdscr.addstr(height-1 , width-12, f" {VERSION_NO} ", curses.A_DIM)
|
||||
stdscr.addstr(height-1 , width-12, f" {PROJECT_VER} ", curses.A_DIM)
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
@@ -1141,7 +1141,8 @@ class Orchestrator:
|
||||
self.fc_ros_manager.initialize()
|
||||
self.fc_ros_manager.status_publisher.rate_controller.topic_intervals = {
|
||||
'position': 1.0,
|
||||
'attitude': 0.0,
|
||||
'position_ned': 1.0,
|
||||
'attitude': 1.0,
|
||||
'velocity': 0.0,
|
||||
'battery': 1.0,
|
||||
'vfr_hud': 1.0,
|
||||
@@ -1339,6 +1340,7 @@ class Orchestrator:
|
||||
self.occupied_ip_ports[ip].append(port)
|
||||
else:
|
||||
self.occupied_ip_ports[ip] = [port]
|
||||
|
||||
# 外放資訊部分
|
||||
elif self.panelState.udp_info_temp["direction"] == "outbound":
|
||||
connection_string = f"udpout:{self.panelState.udp_info_temp['IP']}:{self.panelState.udp_info_temp['Port']}"
|
||||
@@ -1558,8 +1560,27 @@ class Orchestrator:
|
||||
|
||||
def main():
|
||||
|
||||
stop_evt = threading.Event()
|
||||
# =========== 各項模組的版本先驗 ===========
|
||||
# 除非你有在做這幾項模組的改版 不然動到這邊的版本號 代表執行環境有很大的問題!!!!!!
|
||||
version_check = True
|
||||
if mo.MODULE_VER != "1.50":
|
||||
print("Module Version Error! : mavlinkObkect")
|
||||
version_check = False
|
||||
if mros.MODULE_VER != "1.50":
|
||||
print("Module Version Error! : mavlinkROS2Nodes")
|
||||
version_check = False
|
||||
if mvv.MODULE_VER != "1.00":
|
||||
print("Module Version Error! : mavlinkVehicleView")
|
||||
version_check = False
|
||||
if sm.MODULE_VER != "0.60":
|
||||
print("Module Version Error! : serialManager")
|
||||
version_check = False
|
||||
if version_check == False:
|
||||
print("Environment Obstacle! Check YOUR Execution System Path First!!")
|
||||
return
|
||||
# ========================================
|
||||
|
||||
stop_evt = threading.Event()
|
||||
def signal_handler(signum, frame):
|
||||
"""處理 Ctrl+C 信號 藉此訊號 會結束下面的 while 迴圈 並逐步關閉各模組"""
|
||||
stop_evt.set()
|
||||
@@ -1593,4 +1614,7 @@ if __name__ == "__main__":
|
||||
4. 註解掉無效代碼 action == "UPDATE_VEHICLES_LIST" 區塊
|
||||
5. 系統納入 mavlink ROS2 Manager 模組
|
||||
|
||||
2025.04.13
|
||||
1. 加入各項模組的版本先驗 檢驗失敗就直接離開
|
||||
|
||||
'''
|
||||
|
||||
@@ -28,9 +28,6 @@ mavlink_bridge:
|
||||
專門處理 stream_bridge_ring 裡面的訊息流
|
||||
會把訊息流解開後 存放到 mavlinkVehicleView.py 定義的載具結構視圖
|
||||
|
||||
|
||||
|
||||
|
||||
'''
|
||||
|
||||
# 基礎功能的 import
|
||||
@@ -61,6 +58,8 @@ from .utils import RingBuffer, setup_logger
|
||||
# ====================== 分割線 =====================
|
||||
|
||||
logger = setup_logger(os.path.basename(__file__))
|
||||
MODULE_VER = "1.50"
|
||||
|
||||
|
||||
stream_bridge_ring = RingBuffer(capacity=1024, buffer_id=255)
|
||||
return_packet_ring = RingBuffer(capacity=256, buffer_id=254)
|
||||
@@ -386,7 +385,7 @@ class mavlink_object:
|
||||
self.MAVLink = mav_common.MAVLink(self.mavlinkPipeline, srcSystem=254, srcComponent=191)
|
||||
|
||||
# 記錄訊息過濾類型 (可選)
|
||||
self.bridge_msg_types = set([0, 30, 33, 74, 147]) # 0 HEARTBEAT, 30 ATTITUDE, 33 GLOBAL_POSITION_INT, 74 VFR_HUD, 147 BATTERY_STATUS
|
||||
self.bridge_msg_types = set([0, 30, 32, 33, 74, 147]) # 0 HEARTBEAT, 30 ATTITUDE, 33 GLOBAL_POSITION_INT, 74 VFR_HUD, 147 BATTERY_STATUS, 32 LOCAL_POSITION_NED
|
||||
self.return_msg_types = set([])
|
||||
|
||||
# 轉發到別的 mavlink object 作為目標端口 的列表
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""
|
||||
MAVLink ROS2 Nodes
|
||||
包含兩個獨立的 ROS2 Node:
|
||||
主要包含兩個獨立的 ROS2 Node :
|
||||
1. VehicleStatusPublisher - 發布載具狀態到 ROS2 topics
|
||||
從 vehicle_registry 讀取狀態數據,頻率控制,模組化設計
|
||||
2. MavlinkCommandService - 提供 MAVLink 指令 service 介面
|
||||
以最基礎的 mavlink microservice 會實現目標
|
||||
並不會包含額外的功能
|
||||
|
||||
與一個節點管理器
|
||||
- fc_ros_manager
|
||||
|
||||
從 vehicle_registry 讀取狀態數據,頻率控制,模組化設計
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -22,10 +27,12 @@ from rclpy.executors import MultiThreadedExecutor
|
||||
import std_msgs.msg
|
||||
import sensor_msgs.msg
|
||||
import geometry_msgs.msg
|
||||
import nav_msgs.msg
|
||||
import mavros_msgs.msg
|
||||
|
||||
# ROS2 Service imports
|
||||
import fc_interfaces.srv as fcsrv
|
||||
from .ackEnum import serviceAckResult
|
||||
|
||||
# 自定義 imports
|
||||
from . import mavlinkVehicleView as mvv
|
||||
@@ -33,12 +40,13 @@ from . import mavlinkObject as mo
|
||||
from .utils import setup_logger
|
||||
|
||||
logger = setup_logger(os.path.basename(__file__))
|
||||
|
||||
MODULE_VER = "1.50"
|
||||
|
||||
# ============================================================================
|
||||
# VehicleStatusPublisher Node
|
||||
# ============================================================================
|
||||
|
||||
# 頻率控制器
|
||||
# ============================================================================
|
||||
|
||||
class PublishRateController:
|
||||
"""發布頻率控制器 - 按時間間隔控制發布頻率"""
|
||||
|
||||
@@ -49,14 +57,17 @@ class PublishRateController:
|
||||
# 例如:'ekf_status': 1.0, # EKF 狀態
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 各 topic 的發布間隔(秒)
|
||||
# 注意 這邊是定義區 不要把參數寫在這裡 所以預設全部關閉
|
||||
# 以這個專案 請看 mainOrchestrator.py 的 Orchestrator 初始化階段
|
||||
self.topic_intervals = {
|
||||
'position': 0.5, # GPS位置
|
||||
'attitude': 0.5, # 姿態
|
||||
'velocity': 0.5, # 速度
|
||||
'battery': 1.0, # 電池
|
||||
'vfr_hud': 0.5, # VFR HUD
|
||||
'mode': 1.0, # 飛行模式
|
||||
'summary': 1.0, # 載具摘要
|
||||
'position': 0.0, # GNSS位置
|
||||
'position_ned': 0.0, # LOCAL_POSITION_NED (位置+速度)
|
||||
'attitude': 0.0, # 姿態 (pitch yaw row 與其加速狀態)
|
||||
'velocity': 0.0, # 速度 (已經包含在 vfr_hud 未來移除)
|
||||
'battery': 0.0, # 電池
|
||||
'vfr_hud': 0.0, # VFR HUD (地速 空速 絕對高度 爬升率 航向 油門)
|
||||
'mode': 0.0, # 飛行模式 (已經在 summary 裡 未來移除)
|
||||
'summary': 0.0, # 載具摘要 (sysid 飛行模式 解鎖上鎖 gps狀態)
|
||||
# 在這裡新增更多 topics...
|
||||
}
|
||||
# 記錄每個 topic 的最後發布時間 {(sysid, topic): timestamp}
|
||||
@@ -97,11 +108,6 @@ class PublishRateController:
|
||||
"""重置所有計時器"""
|
||||
self.last_publish_time.clear()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# VehicleStatusPublisher Node
|
||||
# ============================================================================
|
||||
|
||||
class VehicleStatusPublisher(Node):
|
||||
"""
|
||||
載具狀態發布者 - 從 vehicle_registry 讀取數據並發布到 ROS2 topics
|
||||
@@ -138,10 +144,8 @@ class VehicleStatusPublisher(Node):
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
# 從 vehicle_registry 獲取所有載具
|
||||
all_vehicles = mvv.vehicle_registry.get_all()
|
||||
|
||||
for sysid, vehicle in all_vehicles.items():
|
||||
# 高頻快路徑:讀取 registry 的 immutable 快照
|
||||
for sysid, vehicle in mvv.vehicle_registry.read_all():
|
||||
self._publish_vehicle_status(vehicle)
|
||||
|
||||
def _publish_vehicle_status(self, vehicle: mvv.VehicleView):
|
||||
@@ -167,6 +171,7 @@ class VehicleStatusPublisher(Node):
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 發布各種狀態(通過頻率控制器判斷是否發布)
|
||||
self._publish_position(sysid, status)
|
||||
self._publish_position_ned(sysid, status)
|
||||
self._publish_attitude(sysid, status)
|
||||
self._publish_velocity(sysid, status)
|
||||
self._publish_battery(sysid, status)
|
||||
@@ -223,6 +228,42 @@ class VehicleStatusPublisher(Node):
|
||||
|
||||
publisher.publish(msg)
|
||||
|
||||
def _publish_position_ned(self, sysid: int, status: mvv.ComponentStatus):
|
||||
"""發布 LOCAL_POSITION_NED(NED 座標,含位置與速度)"""
|
||||
if not self.rate_controller.should_publish(sysid, 'position_ned'):
|
||||
return
|
||||
|
||||
local_pos = status.custom_status.get('local_position')
|
||||
if not local_pos:
|
||||
return
|
||||
|
||||
required_keys = ('x', 'y', 'z', 'vx', 'vy', 'vz')
|
||||
if any(local_pos.get(k) is None for k in required_keys):
|
||||
return
|
||||
|
||||
publisher = self._get_or_create_publisher(
|
||||
sysid, 'position_ned', nav_msgs.msg.Odometry
|
||||
)
|
||||
|
||||
if publisher.get_subscription_count() == 0:
|
||||
return
|
||||
|
||||
msg = nav_msgs.msg.Odometry()
|
||||
msg.header.stamp = self.get_clock().now().to_msg()
|
||||
msg.header.frame_id = 'local_ned'
|
||||
msg.child_frame_id = 'base_link_ned'
|
||||
|
||||
# 保持 MAVLink LOCAL_POSITION_NED 座標定義,不進行 ENU 轉換
|
||||
msg.pose.pose.position.x = float(local_pos['x'])
|
||||
msg.pose.pose.position.y = float(local_pos['y'])
|
||||
msg.pose.pose.position.z = float(local_pos['z'])
|
||||
|
||||
msg.twist.twist.linear.x = float(local_pos['vx'])
|
||||
msg.twist.twist.linear.y = float(local_pos['vy'])
|
||||
msg.twist.twist.linear.z = float(local_pos['vz'])
|
||||
|
||||
publisher.publish(msg)
|
||||
|
||||
def _publish_attitude(self, sysid: int, status: mvv.ComponentStatus):
|
||||
"""發布姿態(IMU)"""
|
||||
if not self.rate_controller.should_publish(sysid, 'attitude'):
|
||||
@@ -231,7 +272,6 @@ class VehicleStatusPublisher(Node):
|
||||
att = status.attitude
|
||||
if att.roll is None:
|
||||
return
|
||||
|
||||
publisher = self._get_or_create_publisher(sysid, 'attitude', sensor_msgs.msg.Imu)
|
||||
|
||||
if publisher.get_subscription_count() == 0:
|
||||
@@ -471,12 +511,17 @@ class MavlinkCommandService(Node):
|
||||
- 作為 service server 等待 client 請求
|
||||
- 接收請求,組裝 MAVLink 封包
|
||||
- 調用 mavlinkObject 發送封包
|
||||
- 處理 ACK 等待和超時(未來實現)
|
||||
- 處理 ACK 等待和超時
|
||||
|
||||
設計理念:回歸 MAVLink 純粹結構
|
||||
- 只負責將 ROS2 請求轉換為 MAVLink 封包
|
||||
- 不預設功能(如 ARM/DISARM) 保持模組化
|
||||
- 高層應用可透過此 service 實現各種功能
|
||||
|
||||
講白話一點就是
|
||||
每次接到一個 service 請求 要整個系統丟某種指令給載具時
|
||||
會做兩件事 1."丟出mavlink封包" 2."創造一個臨時信箱 Pending"
|
||||
|
||||
"""
|
||||
|
||||
serviceString_prefix = '/fc_network/vehicle'
|
||||
@@ -487,8 +532,8 @@ class MavlinkCommandService(Node):
|
||||
# 狀態標記
|
||||
self.running = True
|
||||
|
||||
# mavlinkObject 的引用(將由外部設置)
|
||||
self.mavlink_analyzer = None
|
||||
# # mavlinkObject 的引用(將由外部設置) 用不到
|
||||
# self.mavlink_analyzer = None
|
||||
|
||||
# pending 旗標物件的儲存庫
|
||||
self._pending_by_sysid = {} # sysid(int) : PendingEntry
|
||||
@@ -521,6 +566,15 @@ class MavlinkCommandService(Node):
|
||||
self.handle_command_long
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Service : 發送 SET_POSITION_TARGET_GLOBAL_INT (86)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
self.srv_pos_global_int = self.create_service(
|
||||
fcsrv.MavPositionTargetGlobalInt,
|
||||
self.serviceString_prefix + '/pos_global_int',
|
||||
self.handle_set_position_target_global_int
|
||||
)
|
||||
|
||||
logger.info("MavlinkCommandService initialized")
|
||||
|
||||
def _index(self, target_sysid, target_compid):
|
||||
@@ -542,7 +596,8 @@ class MavlinkCommandService(Node):
|
||||
|
||||
def return_router(self):
|
||||
'''
|
||||
這邊是給外部迴圈呼叫的 消耗 return_packet_ring 裡接收到的 mavlink 封包
|
||||
在節點管理器哪邊被呼叫
|
||||
消耗 return_packet_ring 裡接收到的 mavlink 封包
|
||||
分送到各自的 pending 中
|
||||
藉由 event.set() 解開 service 中的 block
|
||||
'''
|
||||
@@ -568,6 +623,7 @@ class MavlinkCommandService(Node):
|
||||
def handle_mav_timesync_ping(self, request, response):
|
||||
'''
|
||||
用 timesync 封包驗證來回的時間
|
||||
隸屬於 MavLink PING Protocol
|
||||
'''
|
||||
# 設定失效回應
|
||||
response.success = False
|
||||
@@ -612,7 +668,7 @@ class MavlinkCommandService(Node):
|
||||
fail_skip = False
|
||||
# 5) 等待回應封包
|
||||
if not evt.wait(timeout_sec):
|
||||
response.message = "waiting Timeout - TIMESYNC"
|
||||
response.message = "mavlink timeout - TIMESYNC"
|
||||
fail_skip = True
|
||||
|
||||
# 6) 處理回應封包
|
||||
@@ -633,35 +689,31 @@ class MavlinkCommandService(Node):
|
||||
|
||||
return response
|
||||
|
||||
def handle_add_two_ints(self, request, response):
|
||||
"""測試用 Service Handler 未來刪除"""
|
||||
logger.info(f"Received add_two_ints request: {request.a} + {request.b}")
|
||||
response.sum = request.a + request.b
|
||||
logger.info(
|
||||
f"[add_two_ints] thread_ident={threading.get_ident()} time={time.time()}"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
return response
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Service Handler: 發送 COMMAND_LONG
|
||||
# Service Handler: 發送 COMMAND_LONG (76)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
def handle_command_long(self, request, response):
|
||||
'''
|
||||
用 command long 丟出 MAV_CMD
|
||||
隸屬於 MavLink Command Protocol
|
||||
'''
|
||||
# 設定失效回應
|
||||
response.success = False
|
||||
response.message = "Unknown error"
|
||||
response.ack_result = serviceAckResult.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
|
||||
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
|
||||
return response
|
||||
|
||||
# 3) 接收封包系統 的設定
|
||||
@@ -691,7 +743,8 @@ class MavlinkCommandService(Node):
|
||||
fail_skip = False
|
||||
# 5) 等待回應封包
|
||||
if not evt.wait(timeout_sec):
|
||||
response.message = "waiting Timeout - CommLONG"
|
||||
response.message = "mavlink timeout - CommLONG"
|
||||
response.ack_result = serviceAckResult.MAVLINK_TIMEOUT
|
||||
fail_skip = True
|
||||
|
||||
# 6) 處理回應封包
|
||||
@@ -711,6 +764,95 @@ class MavlinkCommandService(Node):
|
||||
|
||||
return response
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Service Handler: 發送 SET_POSITION_TARGET_GLOBAL_INT (86)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
def handle_set_position_target_global_int(self, request, response):
|
||||
'''
|
||||
隸屬於 MavLink Offboard Control Protocol
|
||||
'''
|
||||
# 設定失效回應
|
||||
response.message = "Unknown error"
|
||||
response.ack_result = serviceAckResult.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
|
||||
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
|
||||
return response
|
||||
|
||||
# 3) 接收封包系統 的設定
|
||||
# 在 socket 那邊先把要的封包種類導流進來
|
||||
expect_recieve_msg_id = 87 # POSITION_TARGET_GLOBAL_INT
|
||||
socketObject.set_return_message_types(list(socketObject.return_msg_types) + [expect_recieve_msg_id])
|
||||
evt = threading.Event()
|
||||
# 設定封包檢驗
|
||||
def match_fn(compare_msg):
|
||||
if compare_msg.get_msgId() != expect_recieve_msg_id :
|
||||
return False
|
||||
return True
|
||||
|
||||
# 4) 送出封包
|
||||
socketObject.MAVLink.set_position_target_global_int_send(
|
||||
request.time_boot_ms,
|
||||
request.target_sysid,
|
||||
request.target_compid,
|
||||
request.coordinate_frame,
|
||||
request.type_mask,
|
||||
request.lat_int, request.lon_int, request.alt,
|
||||
request.vx, request.vy, request.vz,
|
||||
request.afx, request.afy, request.afz,
|
||||
request.yaw, request.yaw_rate
|
||||
)
|
||||
|
||||
time.sleep(0.01) # 因應 mavlink 的廣播式回應 後置回應監聽 並且故意延遲10ms 讓系統代謝掉舊的87封包
|
||||
_pending = PendingEntry(event = evt, match_fn = match_fn)
|
||||
self._pending_by_sysid[request.target_sysid] = _pending
|
||||
|
||||
fail_skip = False
|
||||
# 5) 等待回應封包
|
||||
if not evt.wait(timeout_sec):
|
||||
response.message = "mavlink timeout - SET POSITION 86"
|
||||
response.ack_result = MAVLINK_SEND_BUT_NO_EXP_STEAM
|
||||
fail_skip = True
|
||||
|
||||
# 6) 處理回應封包
|
||||
if not fail_skip:
|
||||
ack_msg = _pending.result_mav_msg
|
||||
|
||||
response.message = ""
|
||||
response.ack_result = 0
|
||||
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
|
||||
response.r_lon_int = ack_msg.lon_int
|
||||
response.r_alt = ack_msg.alt
|
||||
response.r_vx = ack_msg.vx
|
||||
response.r_vy = ack_msg.vy
|
||||
response.r_vz = ack_msg.vz
|
||||
response.r_afx = ack_msg.afx
|
||||
response.r_afy = ack_msg.afy
|
||||
response.r_afz = ack_msg.afz
|
||||
response.r_yaw = ack_msg.yaw
|
||||
response.r_yaw_rate = ack_msg.yaw_rate
|
||||
|
||||
# 7) 接收封包系統 的重置
|
||||
msg_types = list(socketObject.return_msg_types)
|
||||
if expect_recieve_msg_id in msg_types:
|
||||
msg_types.remove(expect_recieve_msg_id)
|
||||
socketObject.set_return_message_types(msg_types)
|
||||
del evt
|
||||
self._pending_by_sysid.pop(request.target_sysid, None)
|
||||
|
||||
return response
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Service Handler: 參數請求
|
||||
@@ -791,6 +933,20 @@ class MavlinkCommandService(Node):
|
||||
response.message = "Param request sent (placeholder implementation)"
|
||||
return response
|
||||
|
||||
|
||||
def handle_add_two_ints(self, request, response):
|
||||
"""測試用 Service Handler 未來刪除"""
|
||||
logger.info(f"Received add_two_ints request: {request.a} + {request.b}")
|
||||
response.sum = request.a + request.b
|
||||
logger.info(
|
||||
f"[add_two_ints] thread_ident={threading.get_ident()} time={time.time()}"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
||||
def stop(self):
|
||||
"""停止服務"""
|
||||
self.running = False
|
||||
@@ -837,7 +993,8 @@ class fc_ros_manager:
|
||||
|
||||
try:
|
||||
# 初始化 ROS2
|
||||
rclpy.init()
|
||||
if not rclpy.ok():
|
||||
rclpy.init(args=None)
|
||||
|
||||
# 創建節點 node
|
||||
self.status_publisher = VehicleStatusPublisher()
|
||||
@@ -973,7 +1130,15 @@ ros2_manager = fc_ros_manager()
|
||||
5. 預留 MavlinkCommandService 結構(稍後實現)
|
||||
|
||||
2026.03.27
|
||||
1. 完成 ros2 service 結構與 timesync command_long 協議
|
||||
1. 完成 ros2 service 結構與 timesync 與 command_long 協議
|
||||
|
||||
2026.04.07
|
||||
1. 完成 ros2 的 MavPositionTargetGlobalInt 區域
|
||||
2. 優化 response.ack_result 回傳值的資訊
|
||||
|
||||
|
||||
TODO
|
||||
1. service 部分會需要跟 mavlinkobject 大量互動 也許需要考慮對方的生命週期
|
||||
2. 還沒測試 如果有失效狀態讓 fc_ros_manager 中斷了 如何恢復的問題
|
||||
'''
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ VehicleView - Pure State Container
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, Optional, Any
|
||||
from typing import Dict, Optional, Any, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from .utils import setup_logger
|
||||
|
||||
logger = setup_logger(os.path.basename(__file__))
|
||||
|
||||
MODULE_VER = "1.00"
|
||||
|
||||
# ====================== Enums =====================
|
||||
|
||||
@@ -402,11 +402,18 @@ class VehicleViewRegistry:
|
||||
|
||||
def __init__(self):
|
||||
self._vehicles: Dict[int, VehicleView] = {}
|
||||
# 高頻讀取用 snapshot(弱一致性)
|
||||
self._vehicles_snapshot_items: Tuple[Tuple[int, VehicleView], ...] = tuple()
|
||||
|
||||
def _refresh_snapshot(self) -> None:
|
||||
"""重建快照(僅在 key 集合變動時觸發)"""
|
||||
self._vehicles_snapshot_items = tuple(self._vehicles.items())
|
||||
|
||||
def register(self, sysid: int) -> VehicleView:
|
||||
"""註冊新的載具視圖"""
|
||||
if sysid not in self._vehicles:
|
||||
self._vehicles[sysid] = VehicleView(sysid)
|
||||
self._refresh_snapshot()
|
||||
logger.info(f"Registered new VehicleView for system {sysid}")
|
||||
return self._vehicles[sysid]
|
||||
|
||||
@@ -418,6 +425,7 @@ class VehicleViewRegistry:
|
||||
"""註銷載具視圖"""
|
||||
if sysid in self._vehicles:
|
||||
del self._vehicles[sysid]
|
||||
self._refresh_snapshot()
|
||||
logger.info(f"Unregistered VehicleView for system {sysid}")
|
||||
return True
|
||||
return False
|
||||
@@ -426,9 +434,21 @@ class VehicleViewRegistry:
|
||||
"""取得所有載具視圖"""
|
||||
return self._vehicles.copy()
|
||||
|
||||
def read_all(self) -> Tuple[Tuple[int, VehicleView], ...]:
|
||||
"""
|
||||
高效讀取所有載具視圖快照(弱一致性)
|
||||
|
||||
注意:
|
||||
- 回傳 immutable tuple 快照,適合高頻迭代
|
||||
- 僅在 register/unregister/clear 後更新,可能不是最新資料
|
||||
- 適合高頻讀取且可接受弱一致性的情境
|
||||
"""
|
||||
return self._vehicles_snapshot_items
|
||||
|
||||
def clear(self) -> None:
|
||||
"""清空所有載具視圖"""
|
||||
self._vehicles.clear()
|
||||
self._refresh_snapshot()
|
||||
logger.info("Cleared all VehicleViews")
|
||||
|
||||
def __len__(self) -> int:
|
||||
@@ -449,5 +469,8 @@ vehicle_registry = VehicleViewRegistry()
|
||||
2026.01.16
|
||||
1. 新增 重置指定組件的封包統計 功能
|
||||
|
||||
2026.04.13
|
||||
1. 新增 VehicleViewRegistry.read_all 方法 讓 ROS2 Node 組件更有效率的讀取其中的資料
|
||||
|
||||
'''
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from .utils import setup_logger
|
||||
# ====================== 分割線 =====================
|
||||
|
||||
logger = setup_logger(os.path.basename(__file__))
|
||||
MODULE_VER = "0.60"
|
||||
|
||||
# ====================== 分割線 =====================
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
from .longCommand import CommandLongClient, ChangeModeResult
|
||||
from .changeMode import change_mode
|
||||
from .arm_disarm import arm_disarm
|
||||
from .takeoff import takeoff
|
||||
from .land import land
|
||||
from .longCommand import CommandLongClient, CommandLongResult
|
||||
from .navigation import PositionTargetGlobalIntClient, PositionTargetGlobalIntResult
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CommandLongClient",
|
||||
"ChangeModeResult",
|
||||
"change_mode",
|
||||
"arm_disarm",
|
||||
"takeoff",
|
||||
"land",
|
||||
"PositionTargetGlobalIntClient",
|
||||
"CommandLongResult",
|
||||
"PositionTargetGlobalIntResult",
|
||||
]
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Simple wrapper for arm/disarm via fc_network ROS2 service (MAV_CMD_COMPONENT_ARM_DISARM)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from fc_interfaces.srv import MavCommandLong
|
||||
|
||||
COMMAND_COMPONENT_ARM_DISARM = 400
|
||||
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
|
||||
DEFAULT_TIMEOUT_SEC = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArmDisarmResult:
|
||||
success: bool
|
||||
message: str
|
||||
ack_result: int
|
||||
|
||||
|
||||
def arm_disarm(
|
||||
*,
|
||||
target_sysid: int,
|
||||
arm: bool,
|
||||
target_compid: int = 0,
|
||||
confirmation: int = 0,
|
||||
param2: float = 0.0,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
service_name: str = DEFAULT_SERVICE_NAME,
|
||||
) -> ArmDisarmResult:
|
||||
"""One-shot MAV_CMD_COMPONENT_ARM_DISARM (400) wrapper.
|
||||
|
||||
param1: 1.0 to arm, 0.0 to disarm.
|
||||
param2: usually 0. Some stacks use 21196 for force-arm (ArduPilot); pass via param2 if needed.
|
||||
"""
|
||||
rclpy.init(args=None)
|
||||
node: Optional[Node] = None
|
||||
try:
|
||||
node = Node("fc_arm_disarm_client_once")
|
||||
client = node.create_client(MavCommandLong, service_name)
|
||||
|
||||
if not client.wait_for_service(timeout_sec=timeout_sec):
|
||||
return ArmDisarmResult(
|
||||
success=False,
|
||||
message=f"Service not available: {service_name}",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
req = MavCommandLong.Request()
|
||||
req.target_sysid = target_sysid
|
||||
req.target_compid = target_compid
|
||||
req.command = COMMAND_COMPONENT_ARM_DISARM
|
||||
req.confirmation = confirmation
|
||||
req.param1 = 1.0 if arm else 0.0
|
||||
req.param2 = float(param2)
|
||||
req.param3 = 0.0
|
||||
req.param4 = 0.0
|
||||
req.param5 = 0.0
|
||||
req.param6 = 0.0
|
||||
req.param7 = 0.0
|
||||
req.timeout_sec = float(timeout_sec)
|
||||
|
||||
future = client.call_async(req)
|
||||
rclpy.spin_until_future_complete(node, future, timeout_sec=timeout_sec + 1.0)
|
||||
if not future.done() or future.result() is None:
|
||||
return ArmDisarmResult(
|
||||
success=False,
|
||||
message="Service call timeout or no response.",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
response = future.result()
|
||||
return ArmDisarmResult(
|
||||
success=response.success,
|
||||
message=response.message,
|
||||
ack_result=response.ack_result,
|
||||
)
|
||||
finally:
|
||||
if node is not None:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
r = arm_disarm(target_sysid=3, arm=True)
|
||||
print(
|
||||
f"arm_disarm success={r.success}, "
|
||||
f"ack_result={r.ack_result}, message='{r.message}'"
|
||||
)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Simple wrapper for mode change via fc_network ROS2 service.
|
||||
|
||||
Reference CLI command:
|
||||
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}"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from fc_interfaces.srv import MavCommandLong
|
||||
|
||||
COMMAND_DO_SET_MODE = 176
|
||||
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
|
||||
DEFAULT_TIMEOUT_SEC = 2.0
|
||||
|
||||
@dataclass
|
||||
class ChangeModeResult:
|
||||
"""Return value for mode change requests."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
ack_result: int
|
||||
|
||||
def change_mode(
|
||||
*,
|
||||
target_sysid: int,
|
||||
custom_mode: float,
|
||||
target_compid: int = 0,
|
||||
base_mode: float = 1.0,
|
||||
confirmation: int = 0,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
service_name: str = DEFAULT_SERVICE_NAME,
|
||||
) -> ChangeModeResult:
|
||||
|
||||
"""One-shot helper for collaborators who want minimal code."""
|
||||
rclpy.init(args=None)
|
||||
node: Optional[Node] = None
|
||||
try:
|
||||
node = Node("fc_change_mode_client_once")
|
||||
client = node.create_client(MavCommandLong, service_name)
|
||||
|
||||
if not client.wait_for_service(timeout_sec=timeout_sec):
|
||||
return ChangeModeResult(
|
||||
success=False,
|
||||
message=f"Service not available: {service_name}",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
req = MavCommandLong.Request()
|
||||
req.target_sysid = target_sysid
|
||||
req.target_compid = target_compid
|
||||
req.command = COMMAND_DO_SET_MODE
|
||||
req.confirmation = confirmation
|
||||
req.param1 = float(base_mode)
|
||||
req.param2 = float(custom_mode)
|
||||
req.param3 = 0.0
|
||||
req.param4 = 0.0
|
||||
req.param5 = 0.0
|
||||
req.param6 = 0.0
|
||||
req.param7 = 0.0
|
||||
req.timeout_sec = float(timeout_sec)
|
||||
|
||||
future = client.call_async(req)
|
||||
rclpy.spin_until_future_complete(node, future, timeout_sec=timeout_sec + 1.0)
|
||||
if not future.done() or future.result() is None:
|
||||
return ChangeModeResult(
|
||||
success=False,
|
||||
message="Service call timeout or no response.",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
response = future.result()
|
||||
return ChangeModeResult(
|
||||
success=response.success,
|
||||
message=response.message,
|
||||
ack_result=response.ack_result,
|
||||
)
|
||||
finally:
|
||||
if node is not None:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example values are aligned with your terminal command.
|
||||
result = change_mode(target_sysid=3, custom_mode=4)
|
||||
print(
|
||||
f"change_mode success={result.success}, "
|
||||
f"ack_result={result.ack_result}, message='{result.message}'"
|
||||
)
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Simple wrapper for land via fc_network ROS2 service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from fc_interfaces.srv import MavCommandLong
|
||||
|
||||
COMMAND_NAV_LAND = 21
|
||||
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
|
||||
DEFAULT_TIMEOUT_SEC = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class LandResult:
|
||||
success: bool
|
||||
message: str
|
||||
ack_result: int
|
||||
|
||||
|
||||
def land(
|
||||
*,
|
||||
target_sysid: int,
|
||||
target_compid: int = 0,
|
||||
yaw_deg: float = 0.0,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
altitude_m: float = 0.0,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
service_name: str = DEFAULT_SERVICE_NAME,
|
||||
) -> LandResult:
|
||||
"""One-shot MAV_CMD_NAV_LAND wrapper."""
|
||||
rclpy.init(args=None)
|
||||
node: Optional[Node] = None
|
||||
try:
|
||||
node = Node("fc_land_client_once")
|
||||
client = node.create_client(MavCommandLong, service_name)
|
||||
|
||||
if not client.wait_for_service(timeout_sec=timeout_sec):
|
||||
return LandResult(
|
||||
success=False,
|
||||
message=f"Service not available: {service_name}",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
req = MavCommandLong.Request()
|
||||
req.target_sysid = target_sysid
|
||||
req.target_compid = target_compid
|
||||
req.command = COMMAND_NAV_LAND
|
||||
req.confirmation = 0
|
||||
req.param1 = 0.0
|
||||
req.param2 = 0.0
|
||||
req.param3 = 0.0
|
||||
req.param4 = float(yaw_deg)
|
||||
req.param5 = float(latitude) if latitude is not None else 0.0
|
||||
req.param6 = float(longitude) if longitude is not None else 0.0
|
||||
req.param7 = float(altitude_m)
|
||||
req.timeout_sec = float(timeout_sec)
|
||||
|
||||
future = client.call_async(req)
|
||||
rclpy.spin_until_future_complete(node, future, timeout_sec=timeout_sec + 1.0)
|
||||
if not future.done() or future.result() is None:
|
||||
return LandResult(
|
||||
success=False,
|
||||
message="Service call timeout or no response.",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
response = future.result()
|
||||
return LandResult(
|
||||
success=response.success,
|
||||
message=response.message,
|
||||
ack_result=response.ack_result,
|
||||
)
|
||||
finally:
|
||||
if node is not None:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = land(target_sysid=3)
|
||||
print(
|
||||
f"land success={result.success}, "
|
||||
f"ack_result={result.ack_result}, message='{result.message}'"
|
||||
)
|
||||
@@ -1,8 +1,10 @@
|
||||
"""ROS2 client for fc_network MavCommandLong service (COMMAND_LONG 系列指令)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
@@ -10,30 +12,78 @@ from rclpy.node import Node
|
||||
from fc_interfaces.srv import MavCommandLong
|
||||
|
||||
COMMAND_DO_SET_MODE = 176
|
||||
COMMAND_NAV_LAND = 21
|
||||
COMMAND_NAV_TAKEOFF = 22
|
||||
COMMAND_COMPONENT_ARM_DISARM = 400
|
||||
|
||||
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
|
||||
DEFAULT_TIMEOUT_SEC = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangeModeResult:
|
||||
"""Return value for mode change requests."""
|
||||
|
||||
class CommandLongResult:
|
||||
success: bool
|
||||
message: str
|
||||
ack_result: int
|
||||
|
||||
|
||||
class CommandLongClient(Node):
|
||||
"""Small ROS2 client dedicated to change flight mode."""
|
||||
"""ROS2 client : 對同一個 send_command_long service 發送各種 MAV_CMD_*。"""
|
||||
|
||||
def __init__(self, service_name: str = DEFAULT_SERVICE_NAME) -> None:
|
||||
rclpy.init()
|
||||
super().__init__("fc_change_mode_client")
|
||||
if not rclpy.ok():
|
||||
rclpy.init(args=None)
|
||||
super().__init__("fc_command_long_client")
|
||||
self._client = self.create_client(MavCommandLong, service_name)
|
||||
|
||||
def wait_for_service(self, timeout_sec: float = 3.0) -> bool:
|
||||
return self._client.wait_for_service(timeout_sec=timeout_sec)
|
||||
|
||||
def _send_command_long(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
target_compid: int,
|
||||
command: int,
|
||||
confirmation: int,
|
||||
param1: float,
|
||||
param2: float,
|
||||
param3: float,
|
||||
param4: float,
|
||||
param5: float,
|
||||
param6: float,
|
||||
param7: float,
|
||||
timeout_sec: float,
|
||||
) -> CommandLongResult:
|
||||
req = MavCommandLong.Request()
|
||||
req.target_sysid = target_sysid
|
||||
req.target_compid = target_compid
|
||||
req.command = command
|
||||
req.confirmation = confirmation
|
||||
req.param1 = param1
|
||||
req.param2 = param2
|
||||
req.param3 = param3
|
||||
req.param4 = param4
|
||||
req.param5 = param5
|
||||
req.param6 = param6
|
||||
req.param7 = param7
|
||||
req.timeout_sec = float(timeout_sec)
|
||||
|
||||
future = self._client.call_async(req)
|
||||
rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec + 1.0)
|
||||
if not future.done() or future.result() is None:
|
||||
return CommandLongResult(
|
||||
success=False,
|
||||
message="Service timeout.",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
response = future.result()
|
||||
return CommandLongResult(
|
||||
success=response.success,
|
||||
message=response.message,
|
||||
ack_result=response.ack_result,
|
||||
)
|
||||
|
||||
def change_mode(
|
||||
self,
|
||||
*,
|
||||
@@ -42,34 +92,212 @@ class CommandLongClient(Node):
|
||||
target_compid: int = 0,
|
||||
base_mode: float = 1.0,
|
||||
confirmation: int = 0,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC ) -> ChangeModeResult:
|
||||
|
||||
req = MavCommandLong.Request()
|
||||
req.target_sysid = target_sysid
|
||||
req.target_compid = target_compid
|
||||
req.command = COMMAND_DO_SET_MODE
|
||||
req.confirmation = confirmation
|
||||
req.param1 = float(base_mode)
|
||||
req.param2 = float(custom_mode)
|
||||
req.param3 = 0.0
|
||||
req.param4 = 0.0
|
||||
req.param5 = 0.0
|
||||
req.param6 = 0.0
|
||||
req.param7 = 0.0
|
||||
req.timeout_sec = float(timeout_sec)
|
||||
|
||||
future = self._client.call_async(req)
|
||||
rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec + 1.0)
|
||||
if not future.done() or future.result() is None:
|
||||
return ChangeModeResult(
|
||||
success=False,
|
||||
message="Service call timeout or no response.",
|
||||
ack_result=-1,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
) -> CommandLongResult:
|
||||
return self._send_command_long(
|
||||
target_sysid=target_sysid,
|
||||
target_compid=target_compid,
|
||||
command=COMMAND_DO_SET_MODE,
|
||||
confirmation=confirmation,
|
||||
param1=float(base_mode),
|
||||
param2=float(custom_mode),
|
||||
param3=0.0,
|
||||
param4=0.0,
|
||||
param5=0.0,
|
||||
param6=0.0,
|
||||
param7=0.0,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
response = future.result()
|
||||
return ChangeModeResult(
|
||||
success=response.success,
|
||||
message=response.message,
|
||||
ack_result=response.ack_result,
|
||||
def takeoff(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
altitude_m: float,
|
||||
target_compid: int = 0,
|
||||
min_pitch_deg: float = 0.0,
|
||||
yaw_deg: float = 0.0,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
) -> CommandLongResult:
|
||||
return self._send_command_long(
|
||||
target_sysid=target_sysid,
|
||||
target_compid=target_compid,
|
||||
command=COMMAND_NAV_TAKEOFF,
|
||||
confirmation=0,
|
||||
param1=float(min_pitch_deg),
|
||||
param2=0.0,
|
||||
param3=0.0,
|
||||
param4=float(yaw_deg),
|
||||
param5=float(latitude) if latitude is not None else 0.0,
|
||||
param6=float(longitude) if longitude is not None else 0.0,
|
||||
param7=float(altitude_m),
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
def land(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
target_compid: int = 0,
|
||||
yaw_deg: float = 0.0,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
altitude_m: float = 0.0,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
) -> CommandLongResult:
|
||||
return self._send_command_long(
|
||||
target_sysid=target_sysid,
|
||||
target_compid=target_compid,
|
||||
command=COMMAND_NAV_LAND,
|
||||
confirmation=0,
|
||||
param1=0.0,
|
||||
param2=0.0,
|
||||
param3=0.0,
|
||||
param4=float(yaw_deg),
|
||||
param5=float(latitude) if latitude is not None else 0.0,
|
||||
param6=float(longitude) if longitude is not None else 0.0,
|
||||
param7=float(altitude_m),
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
def arm_disarm(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
arm: bool,
|
||||
target_compid: int = 0,
|
||||
confirmation: int = 0,
|
||||
param2: float = 0.0,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
) -> CommandLongResult:
|
||||
return self._send_command_long(
|
||||
target_sysid=target_sysid,
|
||||
target_compid=target_compid,
|
||||
command=COMMAND_COMPONENT_ARM_DISARM,
|
||||
confirmation=confirmation,
|
||||
param1=1.0 if arm else 0.0,
|
||||
param2=float(param2),
|
||||
param3=0.0,
|
||||
param4=0.0,
|
||||
param5=0.0,
|
||||
param6=0.0,
|
||||
param7=0.0,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# 【新增】非阻塞 async 包裝方法(用於 GUI 的非阻塞調用)
|
||||
# 這些方法在 ThreadPoolExecutor 中運行同步版本,以避免阻塞事件循環
|
||||
# ============================================================================
|
||||
|
||||
async def change_mode_async(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
custom_mode: float,
|
||||
target_compid: int = 0,
|
||||
base_mode: float = 1.0,
|
||||
confirmation: int = 0,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
) -> CommandLongResult:
|
||||
"""非阻塞 async 版本的 change_mode(在 ThreadPoolExecutor 中運行)"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
loop = asyncio.get_event_loop()
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
return await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: self.change_mode(
|
||||
target_sysid=target_sysid,
|
||||
custom_mode=custom_mode,
|
||||
target_compid=target_compid,
|
||||
base_mode=base_mode,
|
||||
confirmation=confirmation,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
)
|
||||
|
||||
async def arm_disarm_async(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
arm: bool,
|
||||
target_compid: int = 0,
|
||||
confirmation: int = 0,
|
||||
param2: float = 0.0,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
) -> CommandLongResult:
|
||||
"""非阻塞 async 版本的 arm_disarm(在 ThreadPoolExecutor 中運行)"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
loop = asyncio.get_event_loop()
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
return await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: self.arm_disarm(
|
||||
target_sysid=target_sysid,
|
||||
arm=arm,
|
||||
target_compid=target_compid,
|
||||
confirmation=confirmation,
|
||||
param2=param2,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
)
|
||||
|
||||
async def takeoff_async(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
altitude_m: float,
|
||||
target_compid: int = 0,
|
||||
min_pitch_deg: float = 0.0,
|
||||
yaw_deg: float = 0.0,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
) -> CommandLongResult:
|
||||
"""非阻塞 async 版本的 takeoff(在 ThreadPoolExecutor 中運行)"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
loop = asyncio.get_event_loop()
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
return await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: self.takeoff(
|
||||
target_sysid=target_sysid,
|
||||
altitude_m=altitude_m,
|
||||
target_compid=target_compid,
|
||||
min_pitch_deg=min_pitch_deg,
|
||||
yaw_deg=yaw_deg,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
)
|
||||
|
||||
async def land_async(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
target_compid: int = 0,
|
||||
yaw_deg: float = 0.0,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
altitude_m: float = 0.0,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
) -> CommandLongResult:
|
||||
"""非阻塞 async 版本的 land(在 ThreadPoolExecutor 中運行)"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
loop = asyncio.get_event_loop()
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
return await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: self.land(
|
||||
target_sysid=target_sysid,
|
||||
target_compid=target_compid,
|
||||
yaw_deg=yaw_deg,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
altitude_m=altitude_m,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""ROS2 client for fc_network MavPositionTargetGlobalInt service (SET_POSITION_TARGET_GLOBAL_INT / Offboard)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from fc_interfaces.srv import MavPositionTargetGlobalInt
|
||||
|
||||
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/pos_global_int"
|
||||
DEFAULT_TIMEOUT_SEC = 2.0
|
||||
|
||||
# MAV_FRAME
|
||||
MAV_FRAME_GLOBAL_RELATIVE_ALT_INT = 6 # 使用 WGS84 GNSS 座標系 高度為相對於 Home 點高度
|
||||
|
||||
# POSITION_TARGET_TYPEMASK
|
||||
# 1 1 Ignore position x
|
||||
# 2 2 Ignore position y
|
||||
# 3 4 Ignore position z
|
||||
# 4 8 Ignore velocity x
|
||||
# 5 16 Ignore velocity y
|
||||
# 6 32 Ignore velocity z
|
||||
# 7 64 Ignore acceleration x
|
||||
# 8 128 Ignore acceleration y
|
||||
# 9 256 Ignore acceleration z
|
||||
# 10 512 Use force instead of acceleration
|
||||
# 11 1024 Ignore yaw
|
||||
# 12 2048 Ignore yaw rate
|
||||
# 13+ Not Used
|
||||
# 6543210987654321
|
||||
POSITION_ONLY = 0b0000110111111000 # 忽略速度、加速度、偏航
|
||||
|
||||
|
||||
# 回傳 echo 與送出值比對時,浮點欄位允許的絕對誤差(MAVLink float 轉傳常有小差)
|
||||
_FLOAT_ECHO_ABS_TOL = 1e-5
|
||||
def _float_echo_equal(a: float, b: float) -> bool:
|
||||
return math.isclose(float(a), float(b), rel_tol=0.0, abs_tol=_FLOAT_ECHO_ABS_TOL)
|
||||
|
||||
|
||||
def _echo_position_target_matches(
|
||||
*,
|
||||
type_mask: int,
|
||||
lat_int: int,
|
||||
lon_int: int,
|
||||
alt: float,
|
||||
vx: float,
|
||||
vy: float,
|
||||
vz: float,
|
||||
afx: float,
|
||||
afy: float,
|
||||
afz: float,
|
||||
yaw: float,
|
||||
yaw_rate: float,
|
||||
time_boot_ms: int,
|
||||
resp,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
比對本次送出的軌跡欄位與 server 填回的 r_* 是否一致。
|
||||
若不符,回傳 (False, 簡短說明);相符則 (True, "")。
|
||||
"""
|
||||
|
||||
_tm_sent = int(type_mask) & 0xFFF
|
||||
_tm_recv = int(resp.r_type_mask) & 0xFFF
|
||||
if _tm_recv != _tm_sent:
|
||||
return False, f"type_mask echo: sent {_tm_sent:012b} != ack {_tm_recv:012b}"
|
||||
|
||||
coordinate_tolerance = 5 * 100
|
||||
if abs(resp.r_lat_int - lat_int) >= coordinate_tolerance:
|
||||
return False, f"lat_int echo: sent {lat_int} != ack {resp.r_lat_int}"
|
||||
if abs(resp.r_lon_int - lon_int) >= coordinate_tolerance:
|
||||
return False, f"lon_int echo: sent {lon_int} != ack {resp.r_lon_int}"
|
||||
# TODO 我給飛控相對高度 它回給我絕對高度 看來這個比較方法 還有很多改良點 但是之後吧
|
||||
# if not _float_echo_equal(resp.r_alt, alt):
|
||||
# return False, f"alt echo: sent {alt} != ack {resp.r_alt}"
|
||||
if not _float_echo_equal(resp.r_vx, vx):
|
||||
return False, f"vx echo: sent {vx} != ack {resp.r_vx}"
|
||||
if not _float_echo_equal(resp.r_vy, vy):
|
||||
return False, f"vy echo: sent {vy} != ack {resp.r_vy}"
|
||||
if not _float_echo_equal(resp.r_vz, vz):
|
||||
return False, f"vz echo: sent {vz} != ack {resp.r_vz}"
|
||||
if not _float_echo_equal(resp.r_afx, afx):
|
||||
return False, f"afx echo: sent {afx} != ack {resp.r_afx}"
|
||||
if not _float_echo_equal(resp.r_afy, afy):
|
||||
return False, f"afy echo: sent {afy} != ack {resp.r_afy}"
|
||||
if not _float_echo_equal(resp.r_afz, afz):
|
||||
return False, f"afz echo: sent {afz} != ack {resp.r_afz}"
|
||||
if not _float_echo_equal(resp.r_yaw, yaw):
|
||||
return False, f"yaw echo: sent {yaw} != ack {resp.r_yaw}"
|
||||
if not _float_echo_equal(resp.r_yaw_rate, yaw_rate):
|
||||
return False, f"yaw_rate echo: sent {yaw_rate} != ack {resp.r_yaw_rate}"
|
||||
# if int(resp.r_time_boot_ms) != int(time_boot_ms):
|
||||
# return False, f"time_boot_ms echo: sent {time_boot_ms} != r {resp.r_time_boot_ms}"
|
||||
return True, ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PositionTargetGlobalIntResult:
|
||||
"""對應 MavPositionTargetGlobalInt.srv 的 Response;success 依「送出與 r_* echo 是否一致」。"""
|
||||
success: bool
|
||||
message: str
|
||||
ack_result: int
|
||||
r_time_boot_ms: int = 0
|
||||
r_type_mask: int = 0
|
||||
r_lat_int: int = 0
|
||||
r_lon_int: int = 0
|
||||
r_alt: float = 0.0
|
||||
r_vx: float = 0.0
|
||||
r_vy: float = 0.0
|
||||
r_vz: float = 0.0
|
||||
r_afx: float = 0.0
|
||||
r_afy: float = 0.0
|
||||
r_afz: float = 0.0
|
||||
r_yaw: float = 0.0
|
||||
r_yaw_rate: float = 0.0
|
||||
|
||||
class PositionTargetGlobalIntClient(Node):
|
||||
"""
|
||||
ROS2 client:對 pos_global_int service 發送 SET_POSITION_TARGET_GLOBAL_INT (msg 86),
|
||||
並等待 POSITION_TARGET_GLOBAL_INT (msg 87) 回應(由 server 端處理)。
|
||||
"""
|
||||
|
||||
def __init__(self, service_name: str = DEFAULT_SERVICE_NAME) -> None:
|
||||
if not rclpy.ok():
|
||||
rclpy.init(args=None)
|
||||
super().__init__("fc_position_target_global_int_client")
|
||||
self._client = self.create_client(MavPositionTargetGlobalInt, service_name)
|
||||
|
||||
def wait_for_service(self, timeout_sec: float = 3.0) -> bool:
|
||||
return self._client.wait_for_service(timeout_sec=timeout_sec)
|
||||
|
||||
def _send_position_target_global_int(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
target_compid: int,
|
||||
time_boot_ms: int,
|
||||
coordinate_frame: int,
|
||||
type_mask: int,
|
||||
lat_int: int,
|
||||
lon_int: int,
|
||||
alt: float,
|
||||
vx: float,
|
||||
vy: float,
|
||||
vz: float,
|
||||
afx: float,
|
||||
afy: float,
|
||||
afz: float,
|
||||
yaw: float,
|
||||
yaw_rate: float,
|
||||
timeout_sec: float,
|
||||
) -> PositionTargetGlobalIntResult:
|
||||
req = MavPositionTargetGlobalInt.Request()
|
||||
req.target_sysid = target_sysid
|
||||
req.target_compid = target_compid
|
||||
req.time_boot_ms = time_boot_ms
|
||||
req.coordinate_frame = coordinate_frame
|
||||
req.type_mask = type_mask
|
||||
req.lat_int = lat_int
|
||||
req.lon_int = lon_int
|
||||
req.alt = float(alt)
|
||||
req.vx = float(vx)
|
||||
req.vy = float(vy)
|
||||
req.vz = float(vz)
|
||||
req.afx = float(afx)
|
||||
req.afy = float(afy)
|
||||
req.afz = float(afz)
|
||||
req.yaw = float(yaw)
|
||||
req.yaw_rate = float(yaw_rate)
|
||||
req.timeout_sec = float(timeout_sec)
|
||||
|
||||
future = self._client.call_async(req)
|
||||
rclpy.spin_until_future_complete(self, future, timeout_sec=float(timeout_sec) + 1.0)
|
||||
if not future.done() or future.result() is None:
|
||||
return PositionTargetGlobalIntResult(
|
||||
success=False,
|
||||
message="Service timeout.",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
resp = future.result()
|
||||
|
||||
if resp.ack_result != 0:
|
||||
return PositionTargetGlobalIntResult(
|
||||
success=False,
|
||||
message=resp.message,
|
||||
ack_result=resp.ack_result,
|
||||
)
|
||||
|
||||
echo_ok, echo_detail = _echo_position_target_matches(
|
||||
type_mask=type_mask,
|
||||
lat_int=lat_int,
|
||||
lon_int=lon_int,
|
||||
alt=alt,
|
||||
vx=vx,
|
||||
vy=vy,
|
||||
vz=vz,
|
||||
afx=afx,
|
||||
afy=afy,
|
||||
afz=afz,
|
||||
yaw=yaw,
|
||||
yaw_rate=yaw_rate,
|
||||
time_boot_ms=time_boot_ms,
|
||||
resp=resp,
|
||||
)
|
||||
# out_message = resp.message
|
||||
# if not echo_ok:
|
||||
# out_message = echo_detail if not out_message else f"{echo_detail} | server: {out_message}"
|
||||
|
||||
return PositionTargetGlobalIntResult(
|
||||
success=echo_ok,
|
||||
message=echo_detail,
|
||||
ack_result=resp.ack_result,
|
||||
r_time_boot_ms=int(resp.r_time_boot_ms),
|
||||
r_type_mask=int(resp.r_type_mask),
|
||||
r_lat_int=int(resp.r_lat_int),
|
||||
r_lon_int=int(resp.r_lon_int),
|
||||
r_alt=float(resp.r_alt),
|
||||
r_vx=float(resp.r_vx),
|
||||
r_vy=float(resp.r_vy),
|
||||
r_vz=float(resp.r_vz),
|
||||
r_afx=float(resp.r_afx),
|
||||
r_afy=float(resp.r_afy),
|
||||
r_afz=float(resp.r_afz),
|
||||
r_yaw=float(resp.r_yaw),
|
||||
r_yaw_rate=float(resp.r_yaw_rate),
|
||||
)
|
||||
|
||||
def goto_position_only(
|
||||
self,
|
||||
*,
|
||||
target_sysid: int,
|
||||
target_compid: int = 0,
|
||||
latitude_deg: float,
|
||||
longitude_deg: float,
|
||||
alt: float = 0.0,
|
||||
timeout_sec: float,
|
||||
):
|
||||
lat_int = int(round(latitude_deg * 1e7))
|
||||
lon_int = int(round(longitude_deg * 1e7))
|
||||
|
||||
return self._send_position_target_global_int(
|
||||
target_sysid=target_sysid,
|
||||
target_compid=target_compid,
|
||||
time_boot_ms= 0,
|
||||
coordinate_frame=MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
|
||||
type_mask=POSITION_ONLY,
|
||||
lat_int=lat_int,
|
||||
lon_int=lon_int,
|
||||
alt=alt,
|
||||
vx=0,
|
||||
vy=0,
|
||||
vz=0,
|
||||
afx=0,
|
||||
afy=0,
|
||||
afz=0,
|
||||
yaw=0,
|
||||
yaw_rate=0,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
@@ -1,91 +0,0 @@
|
||||
"""Simple wrapper for takeoff via fc_network ROS2 service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from fc_interfaces.srv import MavCommandLong
|
||||
|
||||
COMMAND_NAV_TAKEOFF = 22
|
||||
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
|
||||
DEFAULT_TIMEOUT_SEC = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TakeoffResult:
|
||||
success: bool
|
||||
message: str
|
||||
ack_result: int
|
||||
|
||||
|
||||
def takeoff(
|
||||
*,
|
||||
target_sysid: int,
|
||||
altitude_m: float,
|
||||
target_compid: int = 0,
|
||||
min_pitch_deg: float = 0.0,
|
||||
yaw_deg: float = 0.0,
|
||||
latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None,
|
||||
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
|
||||
service_name: str = DEFAULT_SERVICE_NAME,
|
||||
) -> TakeoffResult:
|
||||
"""One-shot MAV_CMD_NAV_TAKEOFF wrapper."""
|
||||
rclpy.init(args=None)
|
||||
node: Optional[Node] = None
|
||||
try:
|
||||
node = Node("fc_takeoff_client_once")
|
||||
client = node.create_client(MavCommandLong, service_name)
|
||||
|
||||
if not client.wait_for_service(timeout_sec=timeout_sec):
|
||||
return TakeoffResult(
|
||||
success=False,
|
||||
message=f"Service not available: {service_name}",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
req = MavCommandLong.Request()
|
||||
req.target_sysid = target_sysid
|
||||
req.target_compid = target_compid
|
||||
req.command = COMMAND_NAV_TAKEOFF
|
||||
req.confirmation = 0
|
||||
req.param1 = float(min_pitch_deg)
|
||||
req.param2 = 0.0
|
||||
req.param3 = 0.0
|
||||
req.param4 = float(yaw_deg)
|
||||
req.param5 = float(latitude) if latitude is not None else 0.0
|
||||
req.param6 = float(longitude) if longitude is not None else 0.0
|
||||
req.param7 = float(altitude_m)
|
||||
req.timeout_sec = float(timeout_sec)
|
||||
|
||||
future = client.call_async(req)
|
||||
rclpy.spin_until_future_complete(node, future, timeout_sec=timeout_sec + 1.0)
|
||||
if not future.done() or future.result() is None:
|
||||
return TakeoffResult(
|
||||
success=False,
|
||||
message="Service call timeout or no response.",
|
||||
ack_result=-1,
|
||||
)
|
||||
|
||||
response = future.result()
|
||||
return TakeoffResult(
|
||||
success=response.success,
|
||||
message=response.message,
|
||||
ack_result=response.ack_result,
|
||||
)
|
||||
finally:
|
||||
if node is not None:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = takeoff(target_sysid=3, altitude_m=10.0)
|
||||
print(
|
||||
f"takeoff success={result.success}, "
|
||||
f"ack_result={result.ack_result}, message='{result.message}'"
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
|
||||
|
||||
from fc_network_apps import CommandLongClient
|
||||
import time
|
||||
|
||||
def main():
|
||||
# Equivalent to:
|
||||
# ros2 service call /fc_network/vehicle/send_command_long ... param1:1 param2:4
|
||||
commandAPI = CommandLongClient()
|
||||
result = commandAPI.change_mode(
|
||||
target_sysid=3,
|
||||
target_compid=0,
|
||||
base_mode=1.0,
|
||||
custom_mode=4.0,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
print("=== change mode result ===")
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
result = commandAPI.change_mode(
|
||||
target_sysid=3,
|
||||
target_compid=0,
|
||||
base_mode=1.0,
|
||||
custom_mode=3.0,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
print("=== change mode result ===")
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
result = commandAPI.change_mode(
|
||||
target_sysid=3,
|
||||
target_compid=0,
|
||||
base_mode=1.0,
|
||||
custom_mode=5.0,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
print("=== change mode result ===")
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Usage example for arm/disarm helper.
|
||||
|
||||
Run from repo root with module mode:
|
||||
python -m someotherpkg.src.example_arm_disarm
|
||||
"""
|
||||
|
||||
from fc_network_apps import arm_disarm
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# MAV_CMD_COMPONENT_ARM_DISARM (command=400)
|
||||
# param1: 1 = arm, 0 = disarm
|
||||
result = arm_disarm(
|
||||
target_sysid=3,
|
||||
target_compid=0,
|
||||
arm=True,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
print("=== arm result ===")
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
|
||||
# To disarm instead:
|
||||
# result = arm_disarm(target_sysid=3, target_compid=0, arm=False, timeout_sec=2.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Usage example for collaborators.
|
||||
|
||||
Run after sourcing your ROS2 workspace:
|
||||
source install/local_setup.bash
|
||||
python src/fc_network_apps/example_change_mode.py
|
||||
"""
|
||||
|
||||
from fc_network_apps import change_mode
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Equivalent to:
|
||||
# ros2 service call /fc_network/vehicle/send_command_long ... param1:1 param2:4
|
||||
result = change_mode(
|
||||
target_sysid=3,
|
||||
target_compid=0,
|
||||
base_mode=1.0,
|
||||
custom_mode=4.0,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
print("=== change mode result ===")
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Usage example for land helper.
|
||||
|
||||
Run from repo root with module mode:
|
||||
python -m someotherpkg.src.example_land
|
||||
"""
|
||||
|
||||
from fc_network_apps import land
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# MAV_CMD_NAV_LAND (command=21)
|
||||
# This example asks vehicle sysid=3 to land.
|
||||
result = land(
|
||||
target_sysid=3,
|
||||
target_compid=0,
|
||||
yaw_deg=0.0,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
print("=== land result ===")
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Usage example for takeoff helper.
|
||||
|
||||
Run from repo root with module mode:
|
||||
python -m someotherpkg.src.example_takeoff
|
||||
"""
|
||||
|
||||
from fc_network_apps import takeoff
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# MAV_CMD_NAV_TAKEOFF (command=22)
|
||||
# This example asks vehicle sysid=3 to take off to 10 meters.
|
||||
result = takeoff(
|
||||
target_sysid=3,
|
||||
target_compid=0,
|
||||
altitude_m=10.0,
|
||||
yaw_deg=0.0,
|
||||
timeout_sec=2.0,
|
||||
)
|
||||
|
||||
print("=== takeoff result ===")
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,111 @@
|
||||
|
||||
|
||||
import fc_network_apps as fap
|
||||
import time
|
||||
|
||||
def main():
|
||||
commandAPI = fap.CommandLongClient()
|
||||
navAPI = fap.PositionTargetGlobalIntClient()
|
||||
|
||||
target_sysid = 3
|
||||
|
||||
|
||||
print("=== change mode ===")
|
||||
result = fap.CommandLongResult(success=False,message="",ack_result=-1)
|
||||
while not result.success:
|
||||
result.ack_result -= 1
|
||||
result = commandAPI.change_mode(
|
||||
target_sysid=target_sysid,
|
||||
base_mode=1.0,
|
||||
custom_mode=4.0,
|
||||
timeout_sec=3.0,
|
||||
)
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
time.sleep(1)
|
||||
if result.ack_result < -3:
|
||||
return
|
||||
|
||||
print("=== arm vehicle ===")
|
||||
result = fap.CommandLongResult(success=False,message="",ack_result=-1)
|
||||
while not result.success:
|
||||
result.ack_result -= 1
|
||||
result = commandAPI.arm_disarm(
|
||||
target_sysid=target_sysid,
|
||||
arm = True,
|
||||
)
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
time.sleep(1)
|
||||
if result.ack_result < -3:
|
||||
return
|
||||
|
||||
print("=== takeoff ===")
|
||||
result = fap.CommandLongResult(success=False,message="",ack_result=-1)
|
||||
while not result.success:
|
||||
result.ack_result -= 1
|
||||
result = commandAPI.takeoff(
|
||||
target_sysid=target_sysid,
|
||||
altitude_m = 100.0,
|
||||
)
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
time.sleep(1)
|
||||
if result.ack_result < -3:
|
||||
return
|
||||
|
||||
|
||||
time.sleep(15)
|
||||
|
||||
print("=== set_position_target_global_int_deg ===")
|
||||
result_deg = navAPI.goto_position_only(
|
||||
target_sysid=target_sysid,
|
||||
latitude_deg=-35.376655,
|
||||
longitude_deg=149.157011,
|
||||
alt=150.0,
|
||||
timeout_sec=3.0,
|
||||
)
|
||||
print(f"success : {result_deg.success}")
|
||||
print(f"ack_result: {result_deg.ack_result}")
|
||||
print(f"message : {result_deg.message}")
|
||||
print(f"r_lat_int : {result_deg.r_lat_int} r_lon_int: {result_deg.r_lon_int}")
|
||||
|
||||
time.sleep(180)
|
||||
|
||||
result_deg = navAPI.goto_position_only(
|
||||
target_sysid=target_sysid,
|
||||
latitude_deg=-35.3632621,
|
||||
longitude_deg=149.1652375,
|
||||
alt=100.0,
|
||||
timeout_sec=3.0,
|
||||
)
|
||||
print("=== set_position_target_global_int_deg ===")
|
||||
print(f"success : {result_deg.success}")
|
||||
print(f"ack_result: {result_deg.ack_result}")
|
||||
print(f"message : {result_deg.message}")
|
||||
print(f"r_lat_int : {result_deg.r_lat_int} r_lon_int: {result_deg.r_lon_int}")
|
||||
|
||||
time.sleep(180)
|
||||
|
||||
print("=== land ===")
|
||||
result = fap.CommandLongResult(success=False,message="",ack_result=-1)
|
||||
while not result.success:
|
||||
result.ack_result -= 1
|
||||
result = commandAPI.land(
|
||||
target_sysid=3,
|
||||
target_compid=0,
|
||||
)
|
||||
print(f"success : {result.success}")
|
||||
print(f"ack_result: {result.ack_result}")
|
||||
print(f"message : {result.message}")
|
||||
time.sleep(1)
|
||||
if result.ack_result < -3:
|
||||
return
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@@ -24,25 +24,34 @@ python -m fc_network_adapter.tests.test_vehicleStatusPublisher
|
||||
python -m fc_network_adapter.tests.test_ringBuffer
|
||||
python -m fc_network_adapter.fc_network_adapter.mainOrchestrator
|
||||
|
||||
python -m someotherpkg.src.example_takeoff_land
|
||||
python -m someotherpkg.src.example_change_mode
|
||||
|
||||
ros2 topic list
|
||||
ros2 topic echo
|
||||
ros2 topic echo /fc_network/vehicle/sys3/battery
|
||||
|
||||
|
||||
/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
|
||||
|
||||
mavproxy.py --master=tcp:127.0.0.1:5790 --out=udp:127.0.0.1:14560
|
||||
|
||||
|
||||
ros2 service call /mavlink/add_two_ints example_interfaces/srv/AddTwoInts "{a: 5,b: 8}"
|
||||
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}"
|
||||
|
||||
MAV_CMD_DO_SET_MODE (176)
|
||||
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}"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user