26 Commits
Author SHA1 Message Date
chiyu1468 bc841156cb Merge branch 'chiyu' 2026-06-11 07:58:03 +08:00
chiyu1468 324fced754 Enhance mavlink handling and add status text support
- Added handling for SYS_STATUS and STATUSTEXT messages in mainOrchestrator.py and mavlinkObject.py.
- Introduced a new StatusTextEntry data class in mavlinkVehicleView.py to manage status text messages.
- Updated topic management in mavlinkROS2Nodes.py to include new status text functionality.
2026-06-11 07:50:50 +08:00
chiyu1468 0a84bb68fe Add SystemDiagnostics message and integrate into network adapter
- Added SystemDiagnosticsRaw.msg to define system diagnostics data structure.
- Enhanced mainOrchestrator.py and mavlinkObject.py to handle SYS_STATUS messages and publish system diagnostics.
- Introduced a new method in VehicleStatusPublisher to publish system diagnostics data.
- Updated mavlinkVehicleView.py to include SystemDiagnostics data class.
2026-06-11 02:50:33 +08:00
wenchunandClaude Opus 4.8 d15c2d0a2d chore: 忽略 MAVProxy/SITL 產生的 tlog 與參數 dump
誤在專案目錄開 SITL 會產生 mav.tlog / mav.tlog.raw / mav.parm,
加入忽略規則避免誤入版控。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:05:59 +08:00
wenchunandClaude Opus 4.8 ae984ecad4 refactor(GUI): 移除規劃路徑的死高度,不再把 GNSS 絕對高度餵進規劃
_collect_drone_gps 原本把 drone_gps['alt'](GNSS AMSL)放進規劃輸入的 z,
但四種任務的指令高度一律取自 mission params,從不讀飛機回報的當下高度,
planner 與驗證視窗也都丟棄該 z。改填 0.0 placeholder、移除沒用的 base_alt
參數,讓「高度非規劃輸入」語意明確化;並移除 gps 更新路徑未使用的 alt 變數。
行為不變,SITL 四模式驗證通過。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:05:59 +08:00
wenchunandClaude Opus 4.8 32aa489462 revert: 還原 mavlinkROS2Nodes 高度發布改動,回到 GUI-only 修改範圍
415aed0 為修 Circle 高度爆衝曾把 position_gnss topic 改發 relative_altitude
(GUI 外的 ROS2 node),違反「修改只動 src/GUI/」的範圍規則。還原回發
AMSL(pos.altitude)。Bug A 的實際修法在 GUI 端(mission_planner stage1.z
改用 final_z)仍保留,故還原後 Circle 高度問題不會復發;後續高度 debug 一律
在 GUI 內處理(需相對高度時讀 NED local_pose z,不讀 drone_gps['alt'])。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:05:26 +08:00
chiyu1468 cc9d005392 (update) ntrip_client.py
Introduced threading locks and stop event for safer socket management. Updated the receive loop to handle connection interruptions.
2026-06-09 14:06:44 +08:00
chiyu1468 2c7f2afc45 (Add) ntrip_client.py GGA sentence generation and sending 2026-06-08 23:09:14 +08:00
wenchunandClaude Opus 4.7 415aed088a fix: 修正實飛 Circle 高度爆衝與 Leader-Follower 卡點
1. mavlinkROS2Nodes: position_gnss topic 改發 relative_altitude 而非 AMSL,
   讓 drone_gps['alt'] 在實飛與 SITL 都代表「相對 home 高度」
2. mission_planner: Circle stage1.z 改用 final_z(不再用 current_z 做中點插值),
   避免實飛時 current_z 是 AMSL 而把過渡高度衝到海拔值的一半
3. mission_executor: 到達判定改為 hover-stable(radius * 1.5 hysteresis +
   穩定計時),預設 arrival_radius 2→4m,解決實飛 GPS 抖動永遠進不了
   2m radius 的卡死問題
4. mission_executor: 新增 progress log,每 3 秒印一行距離與穩定度,
   方便實飛 debug 卡點問題
5. mission_group + gui: 暴露 arrival_radius / hover_stable_sec 給 GUI 任務參數面板,
   每次 start 重讀以支援中途調整

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 16:51:19 +08:00
ken910606 5231cffcb2 Update GUI 2.6.0: trajectory, color, window, serial JSON 2026-06-03 20:15:20 +08:00
chiyu1468 0ee7dd1fcf (hot_fix)
1. 把測試用參數關閉
2. 修正錯誤回傳值
3. 臨時加入 node 失效後 自動重啟功能
2026-06-01 21:09:49 +08:00
chiyu1468 61e283cd25 node重啟功能 與 定義檔優化
---
(Adding)
1. mavlinkROS2Nodes.py 各別 node 重啟的功能
2.  mainRochestrator.py 開發對應介面
(modify)
 1. 定義檔優化 移除 ackEnum.py 新增 ServiceAckResult.msg
2026-05-30 15:15:25 +08:00
ken910606 4e08b18a03 Update GUI 2.5.0: Drone reboot 2026-05-27 19:16:38 +08:00
ken910606 81de964778 Merge branch 'master' of http://140.120.108.238:49308/chiyu1468/AirTrapMine 2026-05-27 19:11:12 +08:00
ken910606 c75537a977 Update GUI 2.5.0: Drone reboot 2026-05-27 19:10:36 +08:00
wenchunandClaude Opus 4.6 8c62f891a8 fix(GUI): 修正實飛三個關鍵 bug — 高度偏移累加、Leader-Follower 不動、起點重疊
1. mission_planner: origin_alt 固定為 0,避免 GPS 橢球高度疊加到相對高度指令
2. mission_planner: 移除圓弧插值,直接用路徑點作為航點,消除微航點導致飛控無法執行
3. mission_planner: lookup 改為外推(extrapolate),解決偶數 rank 起終點重疊
4. communication: UDP/Serial 的 GLOBAL_POSITION_INT 補上 relative_alt 欄位

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-25 18:39:05 +08:00
ken910606 003f8988c5 Update GUI 2.4.1 2026-05-20 20:28:19 +08:00
ken910606 d2aef78a4b Update GUI 2.4.0: GPS Status 2026-05-20 20:02:04 +08:00
ken910606 0b41ea0a1e Update GUI 2.3.1 2026-05-19 11:05:15 +08:00
chiyu1468 5c14c45823 # RTK function update
---
1. mavlinkROS2Node 新增 RtcmRelay Node 監聽 RTCM topic 並轉發到 mavlinkObject bus (GPS_RTCM_DATA)
2. 新增 fc_network_module ros2 package 作為協同附屬模組的放置空間
3. Create a new fc_network_module with NtripClientNode for connecting to NTRIP casters and publishing RTCM data to ROS2 topics.
4. (modify) mainOrchestrator adjust the publishing rates for GNSS topics
2026-05-13 18:14:47 +08:00
chiyu1468 c8f070bfb9 1. (modify) 更新 ros2 對於 GNSS topic 的發佈, 使其包涵 epv eph 衛星數與 fix_type
2. (adding) 新增相應的 msg 檔案與修改 CMakeLists, package
3. (adding) test_vehicleStatusPublisher 相對應修正
2026-05-13 18:14:47 +08:00
ken910606 d2a5f6fad7 Update GUI 2.3.0: JSON in Serial 2026-05-06 20:58:18 +08:00
wenchunandClaude Opus 4.6 730c3e2420 feat(GUI): 新增模擬驗證滑動開關(ToggleSwitch)
在清除軌跡按鈕左邊加入滑動開關,可控制執行任務時是否開啟 matplotlib 驗證視窗。
預設開啟,OFF 時跳過驗證。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-30 10:56:49 +08:00
ken910606 1426a618f4 Update GUI 2.2.0: Settings tab 2026-04-30 06:24:09 +08:00
ken910606 edd15df3fc Update GUI 2.1.0: logs history tab, drone panel attitude, linear twist overview 2026-04-29 20:47:39 +08:00
chiyu1468 7f8babfa5e (update)
1. mavlinkROS2Nodes.py 更新姿態發佈的 topic
2. 新增 interface AttitudeRaw 與對應的 package.xml & CMakeList
2026-04-29 11:37:06 +08:00
38 changed files with 3653 additions and 1114 deletions
+5
View File
@@ -32,3 +32,8 @@ CLAUDE.md
my_env_notes.md my_env_notes.md
notes/ notes/
**/eeprom.bin **/eeprom.bin
# MAVProxy / SITL 執行產生的 log 與參數 dump
*.tlog
*.tlog.raw
mav.parm
+25 -15
View File
@@ -2,9 +2,15 @@
=== ===
## 功能簡介 ## 功能簡介
1. mavlink 多對多支援平台 1. mavlink 多對多串流與交換
2. 不允許進到 ros 系統有相同 sysid 2. 支援 xbee 與 telemetry
3. 假設一台載具上所有 component 共用同一 socket 3. 支援 udp 管理
4. ROS2 介面監控載具
===
使用限制
1. 不允許進到 ros 系統有相同 sysid
2. 假設一台載具上所有 component 共用同一 socket
=== ===
## 運行環境 ## 運行環境
@@ -73,25 +79,29 @@ python gui.py
=== ===
## 資料夾說明 ## 資料夾說明
1. unitdev 為各自協作者做開發時的測試區 1. fc_network_adapter (核心)
01 -> 晉凱(ken)
02 -> 其宇(chiyu)
03 -> 文鈞
04 -> 倫渝
2. fc_network_adapter (核心)
建立、維護與飛控韌體的連接 建立、維護與飛控韌體的連接
構築 mavlink 封包 構築 mavlink 封包
處理無線模組的通訊格式 (XBee) 處理無線模組的通訊格式 (XBee)
--同時處理與 Gazebo 的 ardupilot_plugin 溝通的 FDM/JSON 訊息 (移除)-- 2. fc_interfaces (必要)
3. fc_interfaces (重要) 自定義的 ROS2 介面檔 沒有這個核心會運作不了
自定義的 ROS2 介面檔 3. fc_network_module (重要)
非核心 但是支援載具的重要附屬功能
需要該功能時 作為一個 ros2 節點打開
例如 : ntrip rtk 訊號轉接
4. fc_network_apps 4. fc_network_apps
與 fc_network_adapter 銜接做高階功能包裝的應用小程式 利於開發GUI或其他應用 是 fc_network_adapter 高階包裝API
使用者的外層包裝 利於開發GUI或其他應用 使用者的外層包裝
可以不使用 或者當作一個範例程式來看
5. someotherpkg 5. someotherpkg
如何使用 fc_network_apps 的範例檔案 如何使用 fc_network_apps 的範例檔案
6. GUI 6. GUI
由 PyQt6 開發的互動式介面 由 PyQt6 開發的互動式介面
N. logs 是執行時期的記錄檔 7. unitdev 為各自協作者做開發時的測試區
01 -> 晉凱(ken)
02 -> 其宇(chiyu)
03 -> 文鈞
04 -> 倫渝
8. logs 是執行時期的記錄檔
=== ===
+126 -39
View File
@@ -1,10 +1,78 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QLineEdit, QComboBox) QPushButton, QLineEdit, QComboBox, QApplication)
from PyQt6.QtGui import QFont
from PyQt6.QtCore import pyqtSignal from PyQt6.QtCore import pyqtSignal
import glob import glob
import os import os
def _get_font_scale():
app = QApplication.instance()
if app is None:
return 1.0
scale = app.property("font_scale")
try:
return float(scale) if scale is not None else 1.0
except (TypeError, ValueError):
return 1.0
def _scale_stylesheet_font_sizes(stylesheet, scale):
if not stylesheet or 'font-size' not in stylesheet:
return stylesheet
import re
def repl(match):
size = float(match.group(1))
unit = match.group(2)
scaled = max(1.0, size * scale)
text = f"{scaled:.2f}".rstrip('0').rstrip('.')
return f"font-size: {text}{unit}"
return re.sub(r'font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*(px|pt)', repl, stylesheet)
def _set_scaled_stylesheet(widget, stylesheet):
widget._base_stylesheet = stylesheet
scaled = _scale_stylesheet_font_sizes(stylesheet, _get_font_scale())
widget._applied_stylesheet = scaled
widget.setStyleSheet(scaled)
def _reapply_scaled_stylesheet(widget):
current_stylesheet = widget.styleSheet()
base_stylesheet = getattr(widget, '_base_stylesheet', None)
applied_stylesheet = getattr(widget, '_applied_stylesheet', None)
if current_stylesheet != applied_stylesheet:
base_stylesheet = current_stylesheet
widget._base_stylesheet = base_stylesheet
if base_stylesheet is not None:
scaled = _scale_stylesheet_font_sizes(base_stylesheet, _get_font_scale())
widget._applied_stylesheet = scaled
if current_stylesheet != scaled:
widget.setStyleSheet(scaled)
def _apply_scaled_font(widget):
base_font = getattr(widget, '_base_font_for_scale', None)
if base_font is None:
app = QApplication.instance()
app_base_font = app.property("base_app_font") if app else None
base_font = QFont(app_base_font) if app_base_font is not None else QFont(widget.font())
widget._base_font_for_scale = QFont(base_font)
scaled_font = QFont(base_font)
scale = _get_font_scale()
if base_font.pointSizeF() > 0:
scaled_font.setPointSizeF(max(1.0, base_font.pointSizeF() * scale))
elif base_font.pointSize() > 0:
scaled_font.setPointSize(max(1, int(round(base_font.pointSize() * scale))))
widget.setFont(scaled_font)
class CommPanel(QWidget): class CommPanel(QWidget):
"""通讯设置面板类""" """通讯设置面板类"""
@@ -26,6 +94,7 @@ class CommPanel(QWidget):
self.ws_connections = [] self.ws_connections = []
self.serial_connections = [] self.serial_connections = []
self._init_ui() self._init_ui()
self.apply_font_scale()
def _init_ui(self): def _init_ui(self):
"""初始化UI""" """初始化UI"""
@@ -35,7 +104,7 @@ class CommPanel(QWidget):
# ========== UDP MAVLink 區域 ========== # ========== UDP MAVLink 區域 ==========
udp_title = QLabel("UDP") udp_title = QLabel("UDP")
udp_title.setStyleSheet(""" _set_scaled_stylesheet(udp_title, """
color: #DDD; color: #DDD;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
@@ -60,7 +129,7 @@ class CommPanel(QWidget):
self.udp_ip_input = QLineEdit() self.udp_ip_input = QLineEdit()
self.udp_ip_input.setText("127.0.0.1") self.udp_ip_input.setText("127.0.0.1")
self.udp_ip_input.setPlaceholderText("IP") self.udp_ip_input.setPlaceholderText("IP")
self.udp_ip_input.setStyleSheet(""" _set_scaled_stylesheet(self.udp_ip_input, """
QLineEdit { QLineEdit {
background-color: #333; background-color: #333;
color: #DDD; color: #DDD;
@@ -74,7 +143,7 @@ class CommPanel(QWidget):
self.udp_port_input.setText("14550") self.udp_port_input.setText("14550")
self.udp_port_input.setPlaceholderText("Port") self.udp_port_input.setPlaceholderText("Port")
self.udp_port_input.setFixedWidth(80) self.udp_port_input.setFixedWidth(80)
self.udp_port_input.setStyleSheet(""" _set_scaled_stylesheet(self.udp_port_input, """
QLineEdit { QLineEdit {
background-color: #333; background-color: #333;
color: #DDD; color: #DDD;
@@ -86,7 +155,7 @@ class CommPanel(QWidget):
add_udp_btn = QPushButton("添加") add_udp_btn = QPushButton("添加")
add_udp_btn.clicked.connect(self._handle_add_udp) add_udp_btn.clicked.connect(self._handle_add_udp)
add_udp_btn.setStyleSheet(""" _set_scaled_stylesheet(add_udp_btn, """
QPushButton { QPushButton {
background-color: #4CAF50; background-color: #4CAF50;
color: white; color: white;
@@ -98,9 +167,13 @@ class CommPanel(QWidget):
QPushButton:hover { background-color: #45a049; } QPushButton:hover { background-color: #45a049; }
""") """)
add_udp_layout.addWidget(QLabel("IP:", styleSheet="color: #DDD;")) ip_label = QLabel("IP:")
_set_scaled_stylesheet(ip_label, "color: #DDD;")
add_udp_layout.addWidget(ip_label)
add_udp_layout.addWidget(self.udp_ip_input) add_udp_layout.addWidget(self.udp_ip_input)
add_udp_layout.addWidget(QLabel("Port:", styleSheet="color: #DDD;")) port_label = QLabel("Port:")
_set_scaled_stylesheet(port_label, "color: #DDD;")
add_udp_layout.addWidget(port_label)
add_udp_layout.addWidget(self.udp_port_input) add_udp_layout.addWidget(self.udp_port_input)
add_udp_layout.addWidget(add_udp_btn) add_udp_layout.addWidget(add_udp_btn)
@@ -113,7 +186,7 @@ class CommPanel(QWidget):
# ========== Serial 區域 ========== # ========== Serial 區域 ==========
serial_title = QLabel("Serial") serial_title = QLabel("Serial")
serial_title.setStyleSheet(""" _set_scaled_stylesheet(serial_title, """
color: #DDD; color: #DDD;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
@@ -136,7 +209,7 @@ class CommPanel(QWidget):
add_serial_layout.setContentsMargins(0, 0, 0, 0) add_serial_layout.setContentsMargins(0, 0, 0, 0)
self.serial_port_combo = QComboBox() self.serial_port_combo = QComboBox()
self.serial_port_combo.setStyleSheet(""" _set_scaled_stylesheet(self.serial_port_combo, """
QComboBox { QComboBox {
background-color: #333; background-color: #333;
color: #DDD; color: #DDD;
@@ -160,7 +233,7 @@ class CommPanel(QWidget):
refresh_ports_btn.setFixedWidth(35) refresh_ports_btn.setFixedWidth(35)
refresh_ports_btn.clicked.connect(self._refresh_serial_ports) refresh_ports_btn.clicked.connect(self._refresh_serial_ports)
refresh_ports_btn.setToolTip("重新掃描串口") refresh_ports_btn.setToolTip("重新掃描串口")
refresh_ports_btn.setStyleSheet(""" _set_scaled_stylesheet(refresh_ports_btn, """
QPushButton { QPushButton {
background-color: #444; background-color: #444;
color: #DDD; color: #DDD;
@@ -174,9 +247,9 @@ class CommPanel(QWidget):
self.serial_baudrate_combo = QComboBox() self.serial_baudrate_combo = QComboBox()
self.serial_baudrate_combo.addItems(['9600', '19200', '38400', '57600', '115200']) self.serial_baudrate_combo.addItems(['9600', '19200', '38400', '57600', '115200'])
self.serial_baudrate_combo.setCurrentText('57600') self.serial_baudrate_combo.setCurrentText('115200')
self.serial_baudrate_combo.setFixedWidth(100) self.serial_baudrate_combo.setFixedWidth(100)
self.serial_baudrate_combo.setStyleSheet(""" _set_scaled_stylesheet(self.serial_baudrate_combo, """
QComboBox { QComboBox {
background-color: #333; background-color: #333;
color: #DDD; color: #DDD;
@@ -197,7 +270,7 @@ class CommPanel(QWidget):
add_serial_btn = QPushButton("添加") add_serial_btn = QPushButton("添加")
add_serial_btn.clicked.connect(self._handle_add_serial) add_serial_btn.clicked.connect(self._handle_add_serial)
add_serial_btn.setStyleSheet(""" _set_scaled_stylesheet(add_serial_btn, """
QPushButton { QPushButton {
background-color: #4CAF50; background-color: #4CAF50;
color: white; color: white;
@@ -209,10 +282,14 @@ class CommPanel(QWidget):
QPushButton:hover { background-color: #45a049; } QPushButton:hover { background-color: #45a049; }
""") """)
add_serial_layout.addWidget(QLabel("Port:", styleSheet="color: #DDD;")) serial_port_label = QLabel("Port:")
_set_scaled_stylesheet(serial_port_label, "color: #DDD;")
add_serial_layout.addWidget(serial_port_label)
add_serial_layout.addWidget(self.serial_port_combo) add_serial_layout.addWidget(self.serial_port_combo)
add_serial_layout.addWidget(refresh_ports_btn) add_serial_layout.addWidget(refresh_ports_btn)
add_serial_layout.addWidget(QLabel("Baud:", styleSheet="color: #DDD;")) baud_label = QLabel("Baud:")
_set_scaled_stylesheet(baud_label, "color: #DDD;")
add_serial_layout.addWidget(baud_label)
add_serial_layout.addWidget(self.serial_baudrate_combo) add_serial_layout.addWidget(self.serial_baudrate_combo)
add_serial_layout.addWidget(add_serial_btn) add_serial_layout.addWidget(add_serial_btn)
@@ -225,7 +302,7 @@ class CommPanel(QWidget):
# ========== WebSocket 區域 ========== # ========== WebSocket 區域 ==========
ws_title = QLabel("WebSocket") ws_title = QLabel("WebSocket")
ws_title.setStyleSheet(""" _set_scaled_stylesheet(ws_title, """
color: #DDD; color: #DDD;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
@@ -249,7 +326,7 @@ class CommPanel(QWidget):
self.ws_url_input = QLineEdit() self.ws_url_input = QLineEdit()
self.ws_url_input.setPlaceholderText("host") self.ws_url_input.setPlaceholderText("host")
self.ws_url_input.setStyleSheet(""" _set_scaled_stylesheet(self.ws_url_input, """
QLineEdit { QLineEdit {
background-color: #333; background-color: #333;
color: #DDD; color: #DDD;
@@ -261,7 +338,7 @@ class CommPanel(QWidget):
add_ws_btn = QPushButton("添加") add_ws_btn = QPushButton("添加")
add_ws_btn.clicked.connect(self._handle_add_ws) add_ws_btn.clicked.connect(self._handle_add_ws)
add_ws_btn.setStyleSheet(""" _set_scaled_stylesheet(add_ws_btn, """
QPushButton { QPushButton {
background-color: #4CAF50; background-color: #4CAF50;
color: white; color: white;
@@ -273,7 +350,9 @@ class CommPanel(QWidget):
QPushButton:hover { background-color: #45a049; } QPushButton:hover { background-color: #45a049; }
""") """)
add_ws_layout.addWidget(QLabel("URL:", styleSheet="color: #DDD;")) url_label = QLabel("URL:")
_set_scaled_stylesheet(url_label, "color: #DDD;")
add_ws_layout.addWidget(url_label)
add_ws_layout.addWidget(self.ws_url_input) add_ws_layout.addWidget(self.ws_url_input)
add_ws_layout.addWidget(add_ws_btn) add_ws_layout.addWidget(add_ws_btn)
@@ -437,7 +516,7 @@ class CommPanel(QWidget):
def create_udp_connection_panel(self, conn): def create_udp_connection_panel(self, conn):
"""創建 UDP 連接面板""" """創建 UDP 連接面板"""
panel = QWidget() panel = QWidget()
panel.setStyleSheet(""" _set_scaled_stylesheet(panel, """
QWidget { QWidget {
background-color: #2A2A2A; background-color: #2A2A2A;
border-radius: 6px; border-radius: 6px;
@@ -451,22 +530,22 @@ class CommPanel(QWidget):
# 連接資訊 # 連接資訊
info_label = QLabel(f"{conn['name']} - {conn['ip']}:{conn['port']}") info_label = QLabel(f"{conn['name']} - {conn['ip']}:{conn['port']}")
info_label.setStyleSheet("color: #DDD; font-size: 12px;") _set_scaled_stylesheet(info_label, "color: #DDD; font-size: 12px;")
# 狀態指示器 # 狀態指示器
status_label = QLabel("●") status_label = QLabel("●")
if conn.get('enabled', False): if conn.get('enabled', False):
status_label.setStyleSheet("color: #4CAF50; font-size: 16px;") _set_scaled_stylesheet(status_label, "color: #4CAF50; font-size: 16px;")
status_label.setToolTip("運行中") status_label.setToolTip("運行中")
else: else:
status_label.setStyleSheet("color: #888; font-size: 16px;") _set_scaled_stylesheet(status_label, "color: #888; font-size: 16px;")
status_label.setToolTip("已停止") status_label.setToolTip("已停止")
# 控制按鈕 # 控制按鈕
toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動") toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動")
toggle_btn.setFixedWidth(60) toggle_btn.setFixedWidth(60)
toggle_btn.clicked.connect(lambda: self.udp_connection_toggled.emit(conn, toggle_btn, status_label)) toggle_btn.clicked.connect(lambda: self.udp_connection_toggled.emit(conn, toggle_btn, status_label))
toggle_btn.setStyleSheet(""" _set_scaled_stylesheet(toggle_btn, """
QPushButton { QPushButton {
background-color: #444; background-color: #444;
color: #DDD; color: #DDD;
@@ -481,7 +560,7 @@ class CommPanel(QWidget):
remove_btn = QPushButton("移除") remove_btn = QPushButton("移除")
remove_btn.setFixedWidth(60) remove_btn.setFixedWidth(60)
remove_btn.clicked.connect(lambda: self.udp_connection_removed.emit(conn, panel)) remove_btn.clicked.connect(lambda: self.udp_connection_removed.emit(conn, panel))
remove_btn.setStyleSheet(""" _set_scaled_stylesheet(remove_btn, """
QPushButton { QPushButton {
background-color: #d32f2f; background-color: #d32f2f;
color: white; color: white;
@@ -509,7 +588,7 @@ class CommPanel(QWidget):
def create_ws_connection_panel(self, conn): def create_ws_connection_panel(self, conn):
"""創建 WebSocket 連接面板""" """創建 WebSocket 連接面板"""
panel = QWidget() panel = QWidget()
panel.setStyleSheet(""" _set_scaled_stylesheet(panel, """
QWidget { QWidget {
background-color: #2A2A2A; background-color: #2A2A2A;
border-radius: 6px; border-radius: 6px;
@@ -523,22 +602,22 @@ class CommPanel(QWidget):
# 連接資訊 # 連接資訊
info_label = QLabel(f"{conn['name']} - {conn['url']}") info_label = QLabel(f"{conn['name']} - {conn['url']}")
info_label.setStyleSheet("color: #DDD; font-size: 12px;") _set_scaled_stylesheet(info_label, "color: #DDD; font-size: 12px;")
# 狀態指示器 # 狀態指示器
status_label = QLabel("●") status_label = QLabel("●")
if conn.get('enabled', False): if conn.get('enabled', False):
status_label.setStyleSheet("color: #4CAF50; font-size: 16px;") _set_scaled_stylesheet(status_label, "color: #4CAF50; font-size: 16px;")
status_label.setToolTip("運行中") status_label.setToolTip("運行中")
else: else:
status_label.setStyleSheet("color: #888; font-size: 16px;") _set_scaled_stylesheet(status_label, "color: #888; font-size: 16px;")
status_label.setToolTip("已停止") status_label.setToolTip("已停止")
# 控制按鈕 # 控制按鈕
toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動") toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動")
toggle_btn.setFixedWidth(60) toggle_btn.setFixedWidth(60)
toggle_btn.clicked.connect(lambda: self.ws_connection_toggled.emit(conn, toggle_btn, status_label)) toggle_btn.clicked.connect(lambda: self.ws_connection_toggled.emit(conn, toggle_btn, status_label))
toggle_btn.setStyleSheet(""" _set_scaled_stylesheet(toggle_btn, """
QPushButton { QPushButton {
background-color: #444; background-color: #444;
color: #DDD; color: #DDD;
@@ -553,7 +632,7 @@ class CommPanel(QWidget):
remove_btn = QPushButton("移除") remove_btn = QPushButton("移除")
remove_btn.setFixedWidth(60) remove_btn.setFixedWidth(60)
remove_btn.clicked.connect(lambda: self.ws_connection_removed.emit(conn, panel)) remove_btn.clicked.connect(lambda: self.ws_connection_removed.emit(conn, panel))
remove_btn.setStyleSheet(""" _set_scaled_stylesheet(remove_btn, """
QPushButton { QPushButton {
background-color: #d32f2f; background-color: #d32f2f;
color: white; color: white;
@@ -605,7 +684,7 @@ class CommPanel(QWidget):
def create_serial_connection_panel(self, conn): def create_serial_connection_panel(self, conn):
"""創建 Serial 連接面板""" """創建 Serial 連接面板"""
panel = QWidget() panel = QWidget()
panel.setStyleSheet(""" _set_scaled_stylesheet(panel, """
QWidget { QWidget {
background-color: #2A2A2A; background-color: #2A2A2A;
border-radius: 6px; border-radius: 6px;
@@ -619,22 +698,22 @@ class CommPanel(QWidget):
# 連接資訊 # 連接資訊
info_label = QLabel(f"{conn['name']} - {conn['port']} @ {conn['baudrate']}") info_label = QLabel(f"{conn['name']} - {conn['port']} @ {conn['baudrate']}")
info_label.setStyleSheet("color: #DDD; font-size: 12px;") _set_scaled_stylesheet(info_label, "color: #DDD; font-size: 12px;")
# 狀態指示器 # 狀態指示器
status_label = QLabel("●") status_label = QLabel("●")
if conn.get('enabled', False): if conn.get('enabled', False):
status_label.setStyleSheet("color: #4CAF50; font-size: 16px;") _set_scaled_stylesheet(status_label, "color: #4CAF50; font-size: 16px;")
status_label.setToolTip("運行中") status_label.setToolTip("運行中")
else: else:
status_label.setStyleSheet("color: #888; font-size: 16px;") _set_scaled_stylesheet(status_label, "color: #888; font-size: 16px;")
status_label.setToolTip("已停止") status_label.setToolTip("已停止")
# 控制按鈕 # 控制按鈕
toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動") toggle_btn = QPushButton("停止" if conn.get('enabled', False) else "啟動")
toggle_btn.setFixedWidth(60) toggle_btn.setFixedWidth(60)
toggle_btn.clicked.connect(lambda: self.serial_connection_toggled.emit(conn, toggle_btn, status_label)) toggle_btn.clicked.connect(lambda: self.serial_connection_toggled.emit(conn, toggle_btn, status_label))
toggle_btn.setStyleSheet(""" _set_scaled_stylesheet(toggle_btn, """
QPushButton { QPushButton {
background-color: #444; background-color: #444;
color: #DDD; color: #DDD;
@@ -649,7 +728,7 @@ class CommPanel(QWidget):
remove_btn = QPushButton("移除") remove_btn = QPushButton("移除")
remove_btn.setFixedWidth(60) remove_btn.setFixedWidth(60)
remove_btn.clicked.connect(lambda: self.serial_connection_removed.emit(conn, panel)) remove_btn.clicked.connect(lambda: self.serial_connection_removed.emit(conn, panel))
remove_btn.setStyleSheet(""" _set_scaled_stylesheet(remove_btn, """
QPushButton { QPushButton {
background-color: #d32f2f; background-color: #d32f2f;
color: white; color: white;
@@ -684,4 +763,12 @@ class CommPanel(QWidget):
def remove_serial_connection_from_list(self, conn): def remove_serial_connection_from_list(self, conn):
"""從列表中移除 Serial 連接""" """從列表中移除 Serial 連接"""
if conn in self.serial_connections: if conn in self.serial_connections:
self.serial_connections.remove(conn) self.serial_connections.remove(conn)
def apply_font_scale(self):
"""重新套用目前字體倍率到通訊面板。"""
_apply_scaled_font(self)
_reapply_scaled_stylesheet(self)
for child in self.findChildren(QWidget):
_apply_scaled_font(child)
_reapply_scaled_stylesheet(child)
+7 -3
View File
@@ -16,6 +16,10 @@ from PyQt6.QtCore import QObject, pyqtSignal
from pymavlink import mavutil from pymavlink import mavutil
def _log(level, message):
print(f"[{level}] {message}")
class CommandSender(ABC): class CommandSender(ABC):
"""指令發送抽象介面""" """指令發送抽象介面"""
@@ -63,7 +67,7 @@ class MavlinkSender(CommandSender):
""" """
self.connection_string = connection_string self.connection_string = connection_string
self.mav = mavutil.mavlink_connection(connection_string) self.mav = mavutil.mavlink_connection(connection_string)
print(f"MavlinkSender 已建立連線: {connection_string}") _log("INFO", f"MavlinkSender 已建立連線: {connection_string}")
def send_position_global(self, drone_id, sysid, lat, lon, alt): def send_position_global(self, drone_id, sysid, lat, lon, alt):
"""發送 SET_POSITION_TARGET_GLOBAL_INT。drone_id 未使用,保留為介面相容。""" """發送 SET_POSITION_TARGET_GLOBAL_INT。drone_id 未使用,保留為介面相容。"""
@@ -86,7 +90,7 @@ class MavlinkSender(CommandSender):
if self.mav: if self.mav:
self.mav.close() self.mav.close()
self.mav = None self.mav = None
print("MavlinkSender 已關閉") _log("INFO", "MavlinkSender 已關閉")
class Ros2CommandSender(QObject): class Ros2CommandSender(QObject):
@@ -162,4 +166,4 @@ class Ros2CommandSender(QObject):
if not task.done(): if not task.done():
task.cancel() task.cancel()
self._pending.clear() self._pending.clear()
print("Ros2CommandSender 已關閉") _log("INFO", "Ros2CommandSender 已關閉")
+614 -157
View File
File diff suppressed because it is too large Load Diff
+157 -32
View File
@@ -1,10 +1,77 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox) from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, QApplication)
from PyQt6.QtCore import pyqtSignal from PyQt6.QtCore import pyqtSignal
from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QPolygonF from PyQt6.QtGui import QPainter, QPen, QColor, QFont, QPolygonF
from PyQt6.QtCore import QPointF, Qt from PyQt6.QtCore import QPointF, Qt
import math import math
def _get_font_scale():
app = QApplication.instance()
if app is None:
return 1.0
scale = app.property("font_scale")
try:
return float(scale) if scale is not None else 1.0
except (TypeError, ValueError):
return 1.0
def _scale_stylesheet_font_sizes(stylesheet, scale):
if not stylesheet or 'font-size' not in stylesheet:
return stylesheet
import re
def repl(match):
size = float(match.group(1))
unit = match.group(2)
scaled = max(1.0, size * scale)
text = f"{scaled:.2f}".rstrip('0').rstrip('.')
return f"font-size: {text}{unit}"
return re.sub(r'font-size\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*(px|pt)', repl, stylesheet)
def _set_scaled_stylesheet(widget, stylesheet):
widget._base_stylesheet = stylesheet
scaled = _scale_stylesheet_font_sizes(stylesheet, _get_font_scale())
widget._applied_stylesheet = scaled
widget.setStyleSheet(scaled)
def _reapply_scaled_stylesheet(widget):
current_stylesheet = widget.styleSheet()
base_stylesheet = getattr(widget, '_base_stylesheet', None)
applied_stylesheet = getattr(widget, '_applied_stylesheet', None)
if current_stylesheet != applied_stylesheet:
base_stylesheet = current_stylesheet
widget._base_stylesheet = base_stylesheet
if base_stylesheet is not None:
scaled = _scale_stylesheet_font_sizes(base_stylesheet, _get_font_scale())
widget._applied_stylesheet = scaled
if current_stylesheet != scaled:
widget.setStyleSheet(scaled)
def _apply_scaled_font(widget):
base_font = getattr(widget, '_base_font_for_scale', None)
if base_font is None:
app = QApplication.instance()
app_base_font = app.property("base_app_font") if app else None
base_font = QFont(app_base_font) if app_base_font is not None else QFont(widget.font())
widget._base_font_for_scale = QFont(base_font)
scaled_font = QFont(base_font)
scale = _get_font_scale()
if base_font.pointSizeF() > 0:
scaled_font.setPointSizeF(max(1.0, base_font.pointSizeF() * scale))
elif base_font.pointSize() > 0:
scaled_font.setPointSize(max(1, int(round(base_font.pointSize() * scale))))
widget.setFont(scaled_font)
class DronePanel(QWidget): class DronePanel(QWidget):
"""單個無人機面板類別""" """單個無人機面板類別"""
@@ -32,12 +99,13 @@ class DronePanel(QWidget):
self.attitude_indicator = None self.attitude_indicator = None
self._init_ui() self._init_ui()
self.apply_font_scale()
def _init_ui(self): def _init_ui(self):
"""初始化UI""" """初始化UI"""
self.setObjectName(f"panel_{self.drone_id}") self.setObjectName(f"panel_{self.drone_id}")
self.setFixedHeight(140) self.setFixedHeight(160)
self.setStyleSheet(""" _set_scaled_stylesheet(self, """
background-color: #2A2A2A; background-color: #2A2A2A;
border-radius: 8px; border-radius: 8px;
""") """)
@@ -49,7 +117,7 @@ class DronePanel(QWidget):
# 創建內容容器(包含 info 和 control) # 創建內容容器(包含 info 和 control)
content_widget = QWidget() content_widget = QWidget()
content_widget.setStyleSheet("background-color: #333; border-radius: 6px;") _set_scaled_stylesheet(content_widget, "background-color: #333; border-radius: 6px;")
content_layout = QHBoxLayout(content_widget) content_layout = QHBoxLayout(content_widget)
content_layout.setContentsMargins(8, 8, 8, 8) content_layout.setContentsMargins(8, 8, 8, 8)
content_layout.setSpacing(8) content_layout.setSpacing(8)
@@ -82,7 +150,7 @@ class DronePanel(QWidget):
# 勾選框 # 勾選框
self.checkbox = QCheckBox() self.checkbox = QCheckBox()
self.checkbox.setObjectName(f"{self.drone_id}_checkbox") self.checkbox.setObjectName(f"{self.drone_id}_checkbox")
self.checkbox.setStyleSheet(""" _set_scaled_stylesheet(self.checkbox, """
QCheckBox { QCheckBox {
color: #DDD; color: #DDD;
} }
@@ -103,8 +171,8 @@ class DronePanel(QWidget):
) )
# ID 顯示 # ID 顯示
id_label = QLabel(self.display_id) self.id_label = QLabel(self.display_id)
id_label.setStyleSheet(""" _set_scaled_stylesheet(self.id_label, """
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: 14px;
color: #7FFFD4; color: #7FFFD4;
@@ -112,7 +180,7 @@ class DronePanel(QWidget):
""") """)
header_layout.addWidget(self.checkbox) header_layout.addWidget(self.checkbox)
header_layout.addWidget(id_label) header_layout.addWidget(self.id_label)
header_layout.addStretch() header_layout.addStretch()
info_layout.addWidget(header) info_layout.addWidget(header)
@@ -128,8 +196,12 @@ class DronePanel(QWidget):
# 第三行:高度 # 第三行:高度
altitude_row = self._create_altitude_row() altitude_row = self._create_altitude_row()
info_layout.addWidget(altitude_row) info_layout.addWidget(altitude_row)
# 第四行:GNSS 定位狀態
gnss_row = self._create_gnss_row()
info_layout.addWidget(gnss_row)
# 第四行:航向 + 速度 # 第五行:航向 + 速度
nav_row = self._create_nav_row() nav_row = self._create_nav_row()
info_layout.addWidget(nav_row) info_layout.addWidget(nav_row)
@@ -148,15 +220,15 @@ class DronePanel(QWidget):
status_layout.setContentsMargins(0, 0, 0, 0) status_layout.setContentsMargins(0, 0, 0, 0)
status_title = QLabel("狀態:") status_title = QLabel("狀態:")
status_title.setStyleSheet("color: #888; min-width: 50px;") _set_scaled_stylesheet(status_title, "color: #888; min-width: 50px;")
self.mode_label = QLabel("--") self.mode_label = QLabel("--")
self.mode_label.setObjectName(f"{self.drone_id}_mode") self.mode_label.setObjectName(f"{self.drone_id}_mode")
self.mode_label.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(self.mode_label, "color: #DDD;")
self.armed_label = QLabel("--") self.armed_label = QLabel("--")
self.armed_label.setObjectName(f"{self.drone_id}_armed") self.armed_label.setObjectName(f"{self.drone_id}_armed")
self.armed_label.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(self.armed_label, "color: #DDD;")
status_layout.addWidget(status_title) status_layout.addWidget(status_title)
status_layout.addWidget(self.mode_label) status_layout.addWidget(self.mode_label)
@@ -172,15 +244,15 @@ class DronePanel(QWidget):
connection_layout.setContentsMargins(0, 0, 0, 0) connection_layout.setContentsMargins(0, 0, 0, 0)
connection_title = QLabel("Socket:") connection_title = QLabel("Socket:")
connection_title.setStyleSheet("color: #888; min-width: 50px;") _set_scaled_stylesheet(connection_title, "color: #888; min-width: 50px;")
# 根據解析的 drone_id 資訊設定初始值 # 根據解析的 drone_id 資訊設定初始值
self.socket_seq_label = QLabel(self.socket_seq) self.socket_seq_label = QLabel(self.socket_seq)
self.socket_seq_label.setObjectName(f"{self.drone_id}_socket_seq") self.socket_seq_label.setObjectName(f"{self.drone_id}_socket_seq")
self.socket_seq_label.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(self.socket_seq_label, "color: #DDD;")
connection_sep = QLabel(" - ") connection_sep = QLabel(" - ")
connection_sep.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(connection_sep, "color: #DDD;")
# 設定連接方式顯示 # 設定連接方式顯示
connection_type_map = { connection_type_map = {
@@ -193,7 +265,7 @@ class DronePanel(QWidget):
self.connection_type_label = QLabel(connection_type) self.connection_type_label = QLabel(connection_type)
self.connection_type_label.setObjectName(f"{self.drone_id}_connection_type") self.connection_type_label.setObjectName(f"{self.drone_id}_connection_type")
self.connection_type_label.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(self.connection_type_label, "color: #DDD;")
connection_layout.addWidget(connection_title) connection_layout.addWidget(connection_title)
connection_layout.addWidget(self.socket_seq_label) connection_layout.addWidget(self.socket_seq_label)
@@ -210,29 +282,31 @@ class DronePanel(QWidget):
battery_layout.setContentsMargins(0, 0, 0, 0) battery_layout.setContentsMargins(0, 0, 0, 0)
# 顯示百分比 # 顯示百分比
battery_title = QLabel("電池:") battery_title = QLabel("電池:")
battery_title.setStyleSheet("color: #888; min-width: 50px;") _set_scaled_stylesheet(battery_title, "color: #888; min-width: 50px;")
self.battery_pct_label = QLabel("--") self.battery_pct_label = QLabel("--")
self.battery_pct_label.setObjectName(f"{self.drone_id}_battery_pct") self.battery_pct_label.setObjectName(f"{self.drone_id}_battery_pct")
self.battery_pct_label.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(self.battery_pct_label, "color: #DDD;")
# 分隔符 # 分隔符
separator1 = QLabel(" - ") separator1 = QLabel(" - ")
separator1.setStyleSheet("color: #DDD;") separator1.setObjectName(f"{self.drone_id}_battery_sep1")
_set_scaled_stylesheet(separator1, "color: #DDD;")
# 顯示電壓 # 顯示電壓
self.battery_vol_label = QLabel("--") self.battery_vol_label = QLabel("--")
self.battery_vol_label.setObjectName(f"{self.drone_id}_battery_vol") self.battery_vol_label.setObjectName(f"{self.drone_id}_battery_vol")
self.battery_vol_label.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(self.battery_vol_label, "color: #DDD;")
# 分隔符 # 分隔符
separator2 = QLabel(" - ") separator2 = QLabel(" - ")
separator2.setStyleSheet("color: #DDD;") separator2.setObjectName(f"{self.drone_id}_battery_sep2")
_set_scaled_stylesheet(separator2, "color: #DDD;")
# 顯示電池節數 (S count) # 顯示電池節數 (S count)
self.battery_cells_label = QLabel("--") self.battery_cells_label = QLabel("--")
self.battery_cells_label.setObjectName(f"{self.drone_id}_battery_cells") self.battery_cells_label.setObjectName(f"{self.drone_id}_battery_cells")
self.battery_cells_label.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(self.battery_cells_label, "color: #DDD;")
battery_layout.addWidget(battery_title) battery_layout.addWidget(battery_title)
battery_layout.addWidget(self.battery_pct_label) battery_layout.addWidget(self.battery_pct_label)
@@ -251,18 +325,18 @@ class DronePanel(QWidget):
altitude_layout.setContentsMargins(0, 0, 0, 0) altitude_layout.setContentsMargins(0, 0, 0, 0)
altitude_title = QLabel("高度:") altitude_title = QLabel("高度:")
altitude_title.setStyleSheet("color: #888; min-width: 50px;") _set_scaled_stylesheet(altitude_title, "color: #888; min-width: 50px;")
self.altitude_label = QLabel("--") self.altitude_label = QLabel("--")
self.altitude_label.setObjectName(f"{self.drone_id}_altitude") self.altitude_label.setObjectName(f"{self.drone_id}_altitude")
self.altitude_label.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(self.altitude_label, "color: #DDD;")
speed_title = QLabel("速度:") speed_title = QLabel("速度:")
speed_title.setStyleSheet("color: #888; margin-left: 10px;") _set_scaled_stylesheet(speed_title, "color: #888; margin-left: 10px;")
self.speed_label = QLabel("--") self.speed_label = QLabel("--")
self.speed_label.setObjectName(f"{self.drone_id}_speed") self.speed_label.setObjectName(f"{self.drone_id}_speed")
self.speed_label.setStyleSheet("color: #DDD;") _set_scaled_stylesheet(self.speed_label, "color: #DDD;")
altitude_layout.addWidget(altitude_title) altitude_layout.addWidget(altitude_title)
altitude_layout.addWidget(self.altitude_label) altitude_layout.addWidget(self.altitude_label)
@@ -271,6 +345,34 @@ class DronePanel(QWidget):
altitude_layout.addStretch() altitude_layout.addStretch()
return altitude_row return altitude_row
def _create_gnss_row(self):
"""創建 GNSS 定位狀態行。"""
gnss_row = QWidget()
gnss_layout = QHBoxLayout(gnss_row)
gnss_layout.setContentsMargins(0, 0, 0, 0)
fix_title = QLabel("定位:")
_set_scaled_stylesheet(fix_title, "color: #888; min-width: 50px;")
self.fix_type_label = QLabel("--")
self.fix_type_label.setObjectName(f"{self.drone_id}_fix_type_label")
_set_scaled_stylesheet(self.fix_type_label, "color: #DDD;")
error_title = QLabel("誤差(H/V):")
_set_scaled_stylesheet(error_title, "color: #888; margin-left: 8px;")
self.gnss_error_label = QLabel("--")
self.gnss_error_label.setObjectName(f"{self.drone_id}_gnss_error")
_set_scaled_stylesheet(self.gnss_error_label, "color: #DDD;")
gnss_layout.addWidget(fix_title)
gnss_layout.addWidget(self.fix_type_label)
gnss_layout.addWidget(error_title)
gnss_layout.addWidget(self.gnss_error_label)
gnss_layout.addStretch()
return gnss_row
def _create_position_row(self): def _create_position_row(self):
"""位置行已移除(位置座標不再顯示於面板)。""" """位置行已移除(位置座標不再顯示於面板)。"""
@@ -320,6 +422,16 @@ class DronePanel(QWidget):
def is_checked(self): def is_checked(self):
"""獲取勾選狀態""" """獲取勾選狀態"""
return self.checkbox.isChecked() return self.checkbox.isChecked()
def apply_font_scale(self):
"""重新套用目前字體倍率到 panel 內所有元件。"""
_apply_scaled_font(self)
_reapply_scaled_stylesheet(self)
for child in self.findChildren(QWidget):
_apply_scaled_font(child)
_reapply_scaled_stylesheet(child)
if self.attitude_indicator:
self.attitude_indicator.update()
class SocketGroupPanel(QWidget): class SocketGroupPanel(QWidget):
# 定義信號 # 定義信號
@@ -331,11 +443,12 @@ class SocketGroupPanel(QWidget):
self.color = color self.color = color
self.socket_type = socket_type self.socket_type = socket_type
self._init_ui() self._init_ui()
self.apply_font_scale()
def _init_ui(self): def _init_ui(self):
"""初始化UI""" """初始化UI"""
self.setObjectName(f"socket_group_{self.socket_id}") self.setObjectName(f"socket_group_{self.socket_id}")
self.setStyleSheet(""" _set_scaled_stylesheet(self, """
background-color: #1E1E1E; background-color: #1E1E1E;
border-radius: 12px; border-radius: 12px;
""") """)
@@ -352,7 +465,7 @@ class SocketGroupPanel(QWidget):
# 分組勾選框 # 分組勾選框
self.group_checkbox = QCheckBox() self.group_checkbox = QCheckBox()
self.group_checkbox.setObjectName(f"socket_{self.socket_id}_checkbox") self.group_checkbox.setObjectName(f"socket_{self.socket_id}_checkbox")
self.group_checkbox.setStyleSheet(f""" _set_scaled_stylesheet(self.group_checkbox, f"""
QCheckBox {{ color: #DDD; }} QCheckBox {{ color: #DDD; }}
QCheckBox::indicator {{ QCheckBox::indicator {{
width: 14px; width: 14px;
@@ -380,7 +493,7 @@ class SocketGroupPanel(QWidget):
else: else:
title_text = f"Socket {self.socket_id}" title_text = f"Socket {self.socket_id}"
self.title_label = QLabel(title_text) self.title_label = QLabel(title_text)
self.title_label.setStyleSheet(f""" _set_scaled_stylesheet(self.title_label, f"""
font-weight: bold; font-weight: bold;
font-size: 16px; font-size: 16px;
color: {self.color}; color: {self.color};
@@ -430,6 +543,14 @@ class SocketGroupPanel(QWidget):
"""設置分組勾選狀態(支持半選)""" """設置分組勾選狀態(支持半選)"""
self.group_checkbox.setCheckState(state) self.group_checkbox.setCheckState(state)
def apply_font_scale(self):
"""重新套用目前字體倍率到 socket 分組面板。"""
_apply_scaled_font(self)
_reapply_scaled_stylesheet(self)
for child in self.findChildren(QWidget):
_apply_scaled_font(child)
_reapply_scaled_stylesheet(child)
class AttitudeIndicator(QWidget): class AttitudeIndicator(QWidget):
""" """
@@ -503,7 +624,9 @@ class AttitudeIndicator(QWidget):
# pitch ladder (every 10°, ±30°) # pitch ladder (every 10°, ±30°)
p.setPen(QPen(QColor(255, 255, 255, 180), 1)) p.setPen(QPen(QColor(255, 255, 255, 180), 1))
p.setFont(QFont("Arial", 6)) ladder_font = QFont("Arial")
ladder_font.setPointSizeF(max(1.0, 6 * _get_font_scale()))
p.setFont(ladder_font)
for deg in range(-30, 31, 10): for deg in range(-30, 31, 10):
if deg == 0: if deg == 0:
continue continue
@@ -581,6 +704,8 @@ class AttitudeIndicator(QWidget):
# heading text centred (bigger) # heading text centred (bigger)
p.setPen(QPen(QColor("#FFFFFF"))) p.setPen(QPen(QColor("#FFFFFF")))
p.setFont(QFont("Arial", 10, QFont.Weight.Bold)) heading_font = QFont("Arial", weight=QFont.Weight.Bold)
heading_font.setPointSizeF(max(1.0, 10 * _get_font_scale()))
p.setFont(heading_font)
hdg_str = f"{int(self.heading)}°" hdg_str = f"{int(self.heading)}°"
p.drawText(0, int(strip_y), w, strip_h, Qt.AlignmentFlag.AlignCenter, hdg_str) p.drawText(0, int(strip_y), w, strip_h, Qt.AlignmentFlag.AlignCenter, hdg_str)
+1103 -362
View File
File diff suppressed because it is too large Load Diff
+143 -49
View File
@@ -1,8 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtCore import QTimer, pyqtSignal, QObject, pyqtSlot from PyQt6.QtCore import QTimer, pyqtSignal, QObject, pyqtSlot
from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtWebChannel import QWebChannel
def _log(level, message):
print(f"[{level}] {message}")
class DroneMap: class DroneMap:
"""無人機地圖類別 - 負責管理 Leaflet 地圖顯示""" """無人機地圖類別 - 負責管理 Leaflet 地圖顯示"""
@@ -11,6 +18,9 @@ class DroneMap:
self.map_view = QWebEngineView() self.map_view = QWebEngineView()
self.map_loaded = False self.map_loaded = False
self.pending_map_updates = {} self.pending_map_updates = {}
self.font_scale = 1.0
self.socket_colors = {}
self.drone_group_colors = {}
# 創建橋接對象 # 創建橋接對象
self.bridge = MapBridge() self.bridge = MapBridge()
@@ -31,6 +41,7 @@ class DroneMap:
<script src="https://unpkg.com/leaflet-rotatedmarker/leaflet.rotatedMarker.js"></script> <script src="https://unpkg.com/leaflet-rotatedmarker/leaflet.rotatedMarker.js"></script>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script> <script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style> <style>
:root { --ui-font-scale: 1; }
html, body, #map { height: 100%; margin: 0; } html, body, #map { height: 100%; margin: 0; }
#map { #map {
user-select: none; user-select: none;
@@ -65,7 +76,7 @@ class DroneMap:
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: calc(13px * var(--ui-font-scale));
font-weight: bold; font-weight: bold;
box-shadow: 0 2px 5px rgba(0,0,0,0.2); box-shadow: 0 2px 5px rgba(0,0,0,0.2);
} }
@@ -85,7 +96,7 @@ class DroneMap:
} }
.mission-info-row { .mission-info-row {
margin-bottom: 8px; margin-bottom: 8px;
font-size: 12px; font-size: calc(12px * var(--ui-font-scale));
color: #333; color: #333;
} }
.mission-info-label { .mission-info-label {
@@ -104,7 +115,7 @@ class DroneMap:
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: calc(13px * var(--ui-font-scale));
font-weight: bold; font-weight: bold;
margin-top: 8px; margin-top: 8px;
} }
@@ -131,7 +142,7 @@ class DroneMap:
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: calc(13px * var(--ui-font-scale));
font-weight: bold; font-weight: bold;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
@@ -149,7 +160,7 @@ class DroneMap:
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: calc(13px * var(--ui-font-scale));
font-weight: bold; font-weight: bold;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
@@ -167,7 +178,7 @@ class DroneMap:
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: calc(13px * var(--ui-font-scale));
font-weight: bold; font-weight: bold;
transition: background-color 0.2s; transition: background-color 0.2s;
} }
@@ -274,38 +285,37 @@ class DroneMap:
}, 100); }, 100);
}); });
function createArrowIcon(color) { function createArrowIcon(color, groupColor) {
const outlineColor = groupColor || 'transparent';
return L.divIcon({ return L.divIcon({
className: 'drone-arrow', className: 'drone-arrow',
html: ` html: `
<div style=" <div style="
width: 30px; height: 30px; width: 34px; height: 34px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
"> ">
<svg width="30" height="30" viewBox="0 0 30 30"> <svg width="34" height="34" viewBox="0 0 34 34">
<polygon points="15,0 30,30 15,25 0,30" fill="${color || '#FF0000'}" /> <polygon
points="17,3 31,31 17,26 3,31"
fill="${color || '#FF0000'}"
stroke="${outlineColor}"
stroke-width="3"
stroke-linejoin="round"
/>
</svg> </svg>
</div> </div>
`, `,
iconSize: [30, 30], iconSize: [34, 34],
iconAnchor: [15, 15] iconAnchor: [17, 17]
}); });
} }
function getColorBySocketId(id) { function getColorBySocketId(id) {
if (id.startsWith("s0_")) return "#00BFFF"; const match = /^s([^_]+)_/.exec(id || '');
if (id.startsWith("s1_")) return "#FFD700"; const socketId = match ? match[1] : 'default';
if (id.startsWith("s2_")) return "#FF6969"; return socketColors[socketId] || socketColors.default || "#E0E0E0";
if (id.startsWith("s3_")) return "#FF69B4";
if (id.startsWith("s4_")) return "#00FA9A";
if (id.startsWith("s5_")) return "#9370DB";
if (id.startsWith("s6_")) return "#FFA500";
if (id.startsWith("s7_")) return "#20B2AA";
if (id.startsWith("s8_")) return "#7CFC00";
if (id.startsWith("s9_")) return "#FF8C00";
return "#AAAAAA";
} }
function createIdIcon(id) { function createIdIcon(id) {
@@ -322,7 +332,7 @@ class DroneMap:
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: bold; font-weight: bold;
font-size: 12px; font-size: calc(12px * var(--ui-font-scale));
text-shadow: 1px 1px 2px rgba(255,255,255,0.8), -1px -1px 2px rgba(255,255,255,0.8); text-shadow: 1px 1px 2px rgba(255,255,255,0.8), -1px -1px 2px rgba(255,255,255,0.8);
">${sysid}</div>`, ">${sysid}</div>`,
iconSize: [16, 16], iconSize: [16, 16],
@@ -333,7 +343,11 @@ class DroneMap:
var markers = {}; var markers = {};
var idLabels = {}; var idLabels = {};
var trajectories = {}; var trajectories = {};
const maxTrajectoryPoints = 2000;
const trajectoryMinDistanceMeters = 0.05;
var trajectoryLines = {}; var trajectoryLines = {};
var socketColors = {};
var droneGroupColors = {};
var focusedId = null; var focusedId = null;
var initialized = false; var initialized = false;
var trajectoryGroup = L.layerGroup().addTo(map); var trajectoryGroup = L.layerGroup().addTo(map);
@@ -361,6 +375,16 @@ class DroneMap:
var drawStartPoint = null; var drawStartPoint = null;
var mapDragStarted = false; var mapDragStarted = false;
var clickStartPos = null; var clickStartPos = null;
var lastManualMapMoveAt = 0;
var autoCenteringMap = false;
function markManualMapMove() {
if (!autoCenteringMap) {
lastManualMapMoveAt = Date.now();
}
}
map.on('dragstart drag dragend zoomstart zoomend', markManualMapMove);
// 路徑標記變量 (跟隨模式用) // 路徑標記變量 (跟隨模式用)
var routePoints = []; var routePoints = [];
@@ -564,7 +588,7 @@ class DroneMap:
'align-items: center;' + 'align-items: center;' +
'justify-content: center;' + 'justify-content: center;' +
'font-weight: bold;' + 'font-weight: bold;' +
'font-size: 11px;' + 'font-size: calc(11px * var(--ui-font-scale));' +
'border: 2px solid white;' + 'border: 2px solid white;' +
'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' + 'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' +
'">' + idx + '</div>', '">' + idx + '</div>',
@@ -617,6 +641,10 @@ class DroneMap:
} }
} }
// ================================================================================ // ================================================================================
function setFontScale(scale) {
document.documentElement.style.setProperty('--ui-font-scale', scale);
}
// 開始任務 // 開始任務
function startMission() { function startMission() {
@@ -681,7 +709,7 @@ class DroneMap:
color: color, color: color,
weight: 3, weight: 3,
opacity: 0.7, opacity: 0.7,
smoothFactor: 1 smoothFactor: 0.1
}).addTo(trajectoryGroup); }).addTo(trajectoryGroup);
} }
} }
@@ -691,24 +719,32 @@ class DroneMap:
const point = [lat, lon]; const point = [lat, lon];
trajectories[id].push(point); trajectories[id].push(point);
if (trajectories[id].length > 1000) { if (trajectories[id].length > maxTrajectoryPoints) {
trajectories[id].shift(); trajectories[id].shift();
trajectoryLines[id].setLatLngs(trajectories[id]);
} else {
trajectoryLines[id].addLatLng(point);
} }
trajectoryLines[id].setLatLngs([...trajectories[id]]);
} }
function focusOn(id) { function focusOn(id) {
if (!markers[id]) return; if (!markers[id]) return;
focusedId = id; focusedId = id;
var latlng = markers[id].getLatLng(); var latlng = markers[id].getLatLng();
autoCenteringMap = true;
map.flyTo(latlng, map.getZoom()); map.flyTo(latlng, map.getZoom());
setTimeout(() => { autoCenteringMap = false; }, 500);
} }
setInterval(() => { setInterval(() => {
if (focusedId && markers[focusedId]) { if (focusedId && markers[focusedId]) {
if (Date.now() - lastManualMapMoveAt < 1000) {
return;
}
var latlng = markers[focusedId].getLatLng(); var latlng = markers[focusedId].getLatLng();
autoCenteringMap = true;
map.panTo(latlng); map.panTo(latlng);
setTimeout(() => { autoCenteringMap = false; }, 300);
} }
}, 1000); }, 1000);
@@ -716,7 +752,7 @@ class DroneMap:
if (markers[id]) { if (markers[id]) {
const lastPos = markers[id].getLatLng(); const lastPos = markers[id].getLatLng();
const distance = lastPos.distanceTo([lat, lon]); const distance = lastPos.distanceTo([lat, lon]);
if (distance > 1) { if (distance >= trajectoryMinDistanceMeters) {
addTrajectoryPoint(id, lat, lon); addTrajectoryPoint(id, lat, lon);
} }
@@ -730,7 +766,7 @@ class DroneMap:
const color = getColorBySocketId(id); const color = getColorBySocketId(id);
markers[id] = L.marker([lat, lon], { markers[id] = L.marker([lat, lon], {
icon: createArrowIcon(color), icon: createArrowIcon(color, droneGroupColors[id]),
rotationAngle: heading, rotationAngle: heading,
rotationOrigin: 'center' rotationOrigin: 'center'
}) })
@@ -768,6 +804,33 @@ class DroneMap:
} }
} }
function setDroneGroupColors(groupColors) {
droneGroupColors = groupColors || {};
Object.keys(markers).forEach(id => {
markers[id].setIcon(
createArrowIcon(getColorBySocketId(id), droneGroupColors[id])
);
if (typeof markers[id].setRotationAngle === 'function') {
markers[id].setRotationAngle(markers[id].options.rotationAngle || 0);
}
});
}
function setSocketColors(colors) {
socketColors = colors || {};
Object.keys(markers).forEach(id => {
markers[id].setIcon(
createArrowIcon(getColorBySocketId(id), droneGroupColors[id])
);
if (typeof markers[id].setRotationAngle === 'function') {
markers[id].setRotationAngle(markers[id].options.rotationAngle || 0);
}
});
Object.keys(trajectoryLines).forEach(id => {
trajectoryLines[id].setStyle({ color: getColorBySocketId(id) });
});
}
function clearAllTrajectories() { function clearAllTrajectories() {
trajectories = {}; trajectories = {};
Object.values(trajectoryLines).forEach(line => { Object.values(trajectoryLines).forEach(line => {
@@ -802,7 +865,7 @@ class DroneMap:
'width: 22px; height: 22px;' + 'width: 22px; height: 22px;' +
'border-radius: 50%;' + 'border-radius: 50%;' +
'display: flex; align-items: center; justify-content: center;' + 'display: flex; align-items: center; justify-content: center;' +
'font-weight: bold; font-size: 10px;' + 'font-weight: bold; font-size: calc(10px * var(--ui-font-scale));' +
'border: 2px solid white;' + 'border: 2px solid white;' +
'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' + 'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' +
'">' + groupId + '</div>', '">' + groupId + '</div>',
@@ -822,7 +885,7 @@ class DroneMap:
'width: 22px; height: 22px;' + 'width: 22px; height: 22px;' +
'border-radius: 50%;' + 'border-radius: 50%;' +
'display: flex; align-items: center; justify-content: center;' + 'display: flex; align-items: center; justify-content: center;' +
'font-weight: bold; font-size: 14px;' + 'font-weight: bold; font-size: calc(14px * var(--ui-font-scale));' +
'border: 2px solid white;' + 'border: 2px solid white;' +
'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' + 'box-shadow: 0 2px 5px rgba(0,0,0,0.3);' +
'">★</div>', '">★</div>',
@@ -867,14 +930,17 @@ class DroneMap:
# 設置地圖更新計時器 # 設置地圖更新計時器
self.map_update_timer = QTimer() self.map_update_timer = QTimer()
self.map_update_timer.timeout.connect(self.update_map_positions) self.map_update_timer.timeout.connect(self.update_map_positions)
self.map_update_timer.start(200) # 每 200ms 更新一次 self.map_update_timer.start(100) # 每 100ms 更新一次(10Hz)
def _on_map_loaded(self, ok: bool): def _on_map_loaded(self, ok: bool):
"""地圖加載完成回調""" """地圖加載完成回調"""
if ok: if ok:
self.map_loaded = True self.map_loaded = True
self.set_font_scale(self.font_scale)
self.set_socket_colors(self.socket_colors)
self.set_drone_group_colors(self.drone_group_colors)
else: else:
print("⚠️ 地圖加載失敗") _log("ERROR", "地圖載入失敗")
def update_drone_position(self, drone_id, lat, lon, heading): def update_drone_position(self, drone_id, lat, lon, heading):
"""更新無人機位置(加入待處理隊列)""" """更新無人機位置(加入待處理隊列)"""
@@ -887,7 +953,7 @@ class DroneMap:
js_commands = [] js_commands = []
for drone_id, (lat, lon, heading) in self.pending_map_updates.items(): for drone_id, (lat, lon, heading) in self.pending_map_updates.items():
js_commands.append(f"updateDrone({lat:.6f}, {lon:.6f}, '{drone_id}', {heading:.1f});") js_commands.append(f"updateDrone({lat:.8f}, {lon:.8f}, '{drone_id}', {heading:.1f});")
if js_commands: if js_commands:
combined_js = "\n".join(js_commands) combined_js = "\n".join(js_commands)
@@ -899,11 +965,31 @@ class DroneMap:
"""清除所有軌跡""" """清除所有軌跡"""
if self.map_loaded: if self.map_loaded:
self.map_view.page().runJavaScript("clearAllTrajectories();") self.map_view.page().runJavaScript("clearAllTrajectories();")
def set_drone_group_colors(self, drone_group_colors):
"""設定每台無人機的 group 外框顏色;未分組者不傳入即為透明。"""
self.drone_group_colors = dict(drone_group_colors or {})
if self.map_loaded:
payload = json.dumps(self.drone_group_colors)
self.map_view.page().runJavaScript(f"setDroneGroupColors({payload});")
def set_socket_colors(self, socket_colors):
"""設定 socket 顏色,供地圖箭頭本體與軌跡線使用。"""
self.socket_colors = dict(socket_colors or {})
if self.map_loaded:
payload = json.dumps(self.socket_colors)
self.map_view.page().runJavaScript(f"setSocketColors({payload});")
def focus_on_drone(self, drone_id): def focus_on_drone(self, drone_id):
"""聚焦到指定無人機""" """聚焦到指定無人機"""
if self.map_loaded: if self.map_loaded:
self.map_view.page().runJavaScript(f"focusOn('{drone_id}');") self.map_view.page().runJavaScript(f"focusOn('{drone_id}');")
def set_font_scale(self, scale):
"""設定地圖 HTML 控制項的字體倍率。"""
self.font_scale = scale
if self.map_loaded:
self.map_view.page().runJavaScript(f"setFontScale({scale:.3f});")
# ================================================================================ # ================================================================================
# 任務規劃視覺化方法 # 任務規劃視覺化方法
@@ -924,20 +1010,24 @@ class DroneMap:
f"{target_lat:.6f}, {target_lon:.6f});" f"{target_lat:.6f}, {target_lon:.6f});"
) )
self.map_view.page().runJavaScript(js_code) self.map_view.page().runJavaScript(js_code)
print(f"📍 地圖已繪製 Group {group_id} 任務規劃: " _log(
f"C({center_lat:.6f}, {center_lon:.6f}) -> T({target_lat:.6f}, {target_lon:.6f})") "INFO",
f"地圖已繪製 Group {group_id} 任務規劃: "
f"C({center_lat:.6f}, {center_lon:.6f}) -> "
f"T({target_lat:.6f}, {target_lon:.6f})",
)
def clear_mission_plan(self): def clear_mission_plan(self):
"""清除地圖上所有任務規劃標記""" """清除地圖上所有任務規劃標記"""
if self.map_loaded: if self.map_loaded:
self.map_view.page().runJavaScript("clearAllMissionPlans();") self.map_view.page().runJavaScript("clearAllMissionPlans();")
print("🗑️ 地圖已清除所有任務規劃") _log("INFO", "地圖已清除所有任務規劃")
def clear_mission_plan_for_group(self, group_id): def clear_mission_plan_for_group(self, group_id):
"""清除指定群組的任務規劃標記""" """清除指定群組的任務規劃標記"""
if self.map_loaded: if self.map_loaded:
self.map_view.page().runJavaScript(f"clearMissionPlanForGroup('{group_id}');") self.map_view.page().runJavaScript(f"clearMissionPlanForGroup('{group_id}');")
print(f"🗑️ 地圖已清除 Group {group_id} 任務規劃") _log("INFO", f"地圖已清除 Group {group_id} 任務規劃")
def set_mission_mode(self, mode): def set_mission_mode(self, mode):
"""從 Python 端切換地圖的任務模式(觸發框選/路徑標記等)""" """從 Python 端切換地圖的任務模式(觸發框選/路徑標記等)"""
@@ -1033,52 +1123,56 @@ class MapBridge(QObject):
def clearAllDroneSelection(self): def clearAllDroneSelection(self):
"""供 JavaScript 調用的方法 - 清除所有無人機選擇""" """供 JavaScript 調用的方法 - 清除所有無人機選擇"""
self.clear_all_drone_selection.emit() self.clear_all_drone_selection.emit()
print("🗑️ 清除所有無人機選擇") _log("INFO", "已清除所有無人機選擇")
@pyqtSlot() @pyqtSlot()
def toggleSelectAllDrones(self): def toggleSelectAllDrones(self):
"""供 JavaScript 調用的方法 - 切換全選/取消全選所有無人機""" """供 JavaScript 調用的方法 - 切換全選/取消全選所有無人機"""
self.select_all_drones.emit() self.select_all_drones.emit()
print("🔄 切換全選無人機") _log("INFO", "已切換全選無人機")
@pyqtSlot(float, float, float, float) @pyqtSlot(float, float, float, float)
def startMissionSignal(self, center_lat, center_lon, target_lat, target_lon): def startMissionSignal(self, center_lat, center_lon, target_lat, target_lon):
"""供 JavaScript 調用的方法 - 開始任務""" """供 JavaScript 調用的方法 - 開始任務"""
self.start_mission_signal.emit(center_lat, center_lon, target_lat, target_lon) self.start_mission_signal.emit(center_lat, center_lon, target_lat, target_lon)
print(f"🚀 開始任務信號已發出: C({center_lat}, {center_lon}) -> T({target_lat}, {target_lon})") _log(
"INFO",
f"已發出開始任務信號: "
f"C({center_lat}, {center_lon}) -> T({target_lat}, {target_lon})",
)
@pyqtSlot() @pyqtSlot()
def pauseMissionSignal(self): def pauseMissionSignal(self):
"""供 JavaScript 調用的方法 - 暫停任務""" """供 JavaScript 調用的方法 - 暫停任務"""
self.pause_mission_signal.emit() self.pause_mission_signal.emit()
print("⏸️ 暫停任務信號已發出") _log("INFO", "已發出暫停任務信號")
@pyqtSlot(str) @pyqtSlot(str)
def rectangleSelected(self, points_json): def rectangleSelected(self, points_json):
"""供 JavaScript 調用的方法 - 矩形選擇完成""" """供 JavaScript 調用的方法 - 矩形選擇完成"""
self.rectangle_selected.emit(points_json) self.rectangle_selected.emit(points_json)
print(f"📦 矩形區域已選擇: {points_json}") _log("INFO", f"矩形區域已選擇: {points_json}")
@pyqtSlot(str) @pyqtSlot(str)
def polygonSelected(self, points_json): def polygonSelected(self, points_json):
"""供 JavaScript 調用的方法 - 多邊形選擇完成""" """供 JavaScript 調用的方法 - 多邊形選擇完成"""
self.polygon_selected.emit(points_json) self.polygon_selected.emit(points_json)
print(f"🔷 多邊形區域已選擇: {points_json}") _log("INFO", f"多邊形區域已選擇: {points_json}")
@pyqtSlot(str) @pyqtSlot(str)
def missionModeChanged(self, mode): def missionModeChanged(self, mode):
"""供 JavaScript 調用的方法 - 任務模式切換""" """供 JavaScript 調用的方法 - 任務模式切換"""
self.mission_mode_changed.emit(mode) self.mission_mode_changed.emit(mode)
print(f"🔄 任務模式切換: {mode}") _log("INFO", f"任務模式已切換: {mode}")
@pyqtSlot(str) @pyqtSlot(str)
def routeConfirmed(self, points_json): def routeConfirmed(self, points_json):
"""供 JavaScript 調用的方法 - 路徑確認""" """供 JavaScript 調用的方法 - 路徑確認"""
self.route_confirmed.emit(points_json) self.route_confirmed.emit(points_json)
print(f"📍 路徑已確認: {points_json}") _log("INFO", f"路徑已確認: {points_json}")
@pyqtSlot(str) @pyqtSlot(str)
def droneBoxSelected(self, drone_ids_json): def droneBoxSelected(self, drone_ids_json):
"""供 JavaScript 調用的方法 - 框選無人機完成""" """供 JavaScript 調用的方法 - 框選無人機完成"""
self.drone_box_selected.emit(drone_ids_json) self.drone_box_selected.emit(drone_ids_json)
print(f"📦 框選無人機: {drone_ids_json}") _log("INFO", f"框選無人機完成: {drone_ids_json}")
+87 -26
View File
@@ -19,6 +19,10 @@ from enum import Enum
from PyQt6.QtCore import QObject, QTimer, pyqtSignal from PyQt6.QtCore import QObject, QTimer, pyqtSignal
def _log(level, message):
print(f"[{level}] {message}")
class MissionState(Enum): class MissionState(Enum):
"""整體任務狀態""" """整體任務狀態"""
IDLE = "idle" IDLE = "idle"
@@ -39,6 +43,7 @@ class DroneTask:
__slots__ = ( __slots__ = (
'drone_id', 'sysid', 'waypoints', 'wp_index', 'done', 'drone_id', 'sysid', 'waypoints', 'wp_index', 'done',
'sent_current_wp', 'fail_count', 'status', 'waiting_since', 'sent_current_wp', 'fail_count', 'status', 'waiting_since',
'entered_radius_at', 'last_log_at',
) )
def __init__(self, drone_id, sysid, waypoints): def __init__(self, drone_id, sysid, waypoints):
@@ -51,6 +56,8 @@ class DroneTask:
self.fail_count = 0 self.fail_count = 0
self.status = TaskStatus.NORMAL self.status = TaskStatus.NORMAL
self.waiting_since = 0.0 # monotonic time 進入 WAITING_AT_BARRIER 的瞬間 self.waiting_since = 0.0 # monotonic time 進入 WAITING_AT_BARRIER 的瞬間
self.entered_radius_at = 0.0 # 進入 arrival_radius 的時間,0 = 尚未進入
self.last_log_at = 0.0 # 上次印 distance log 的時間
@property @property
def current_target(self): def current_target(self):
@@ -85,14 +92,21 @@ class MissionExecutor(QObject):
mission_completed = pyqtSignal() mission_completed = pyqtSignal()
def __init__(self, sender, drone_gps, monitor=None, def __init__(self, sender, drone_gps, monitor=None,
arrival_radius=2.0, tick_rate_hz=2.0, arrival_radius=4.0, tick_rate_hz=2.0,
barrier_timeout_sec=20.0): barrier_timeout_sec=20.0,
hover_stable_sec=2.0,
progress_log_interval_sec=3.0):
super().__init__() super().__init__()
self.sender = sender self.sender = sender
self.drone_gps = drone_gps self.drone_gps = drone_gps
self.monitor = monitor # 用於失敗 fallback 到 LOITER self.monitor = monitor # 用於失敗 fallback 到 LOITER
self.arrival_radius = arrival_radius self.arrival_radius = arrival_radius
self.barrier_timeout_sec = barrier_timeout_sec self.barrier_timeout_sec = barrier_timeout_sec
# hover-stable 判定:進入 radius 後須穩定停留 hover_stable_sec 秒才算到達,
# 容忍 GPS 抖動跨越邊界(用 radius * 1.5 作 hysteresis)
self.hover_stable_sec = hover_stable_sec
self.hysteresis_factor = 1.5
self.progress_log_interval_sec = progress_log_interval_sec
self.state = MissionState.IDLE self.state = MissionState.IDLE
self.tasks = {} self.tasks = {}
self.rendezvous_indices = set() self.rendezvous_indices = set()
@@ -109,7 +123,7 @@ class MissionExecutor(QObject):
def start(self, planned_waypoints): def start(self, planned_waypoints):
if self.state == MissionState.RUNNING: if self.state == MissionState.RUNNING:
print("任務已在執行中") _log("WARN", "任務已在執行中")
return return
self.tasks.clear() self.tasks.clear()
@@ -132,31 +146,34 @@ class MissionExecutor(QObject):
f"rendezvous WP={sorted(self.rendezvous_indices)}" f"rendezvous WP={sorted(self.rendezvous_indices)}"
if self.rendezvous_indices else "無 rendezvous (各自跑)" if self.rendezvous_indices else "無 rendezvous (各自跑)"
) )
print(f"任務啟動: {len(self.tasks)} 架無人機, " _log(
f"共 {total_wps} 個航點, " "INFO",
f"到達半徑={self.arrival_radius}m, " f"任務已啟動: {len(self.tasks)} 架無人機, "
f"tick 週期={self._interval_ms}ms, " f"共 {total_wps} 個航點, "
f"barrier timeout={self.barrier_timeout_sec}s, " f"到達半徑={self.arrival_radius}m (hover-stable {self.hover_stable_sec}s), "
f"{rv_info}") f"tick 週期={self._interval_ms}ms, "
f"barrier timeout={self.barrier_timeout_sec}s, "
f"{rv_info}",
)
def pause(self): def pause(self):
if self.state == MissionState.RUNNING: if self.state == MissionState.RUNNING:
self._timer.stop() self._timer.stop()
self.state = MissionState.PAUSED self.state = MissionState.PAUSED
print("任務暫停") _log("INFO", "任務已暫停")
def resume(self): def resume(self):
if self.state == MissionState.PAUSED: if self.state == MissionState.PAUSED:
self._timer.start(self._interval_ms) self._timer.start(self._interval_ms)
self.state = MissionState.RUNNING self.state = MissionState.RUNNING
print("任務恢復") _log("INFO", "任務已恢復")
def stop(self): def stop(self):
self._timer.stop() self._timer.stop()
self.tasks.clear() self.tasks.clear()
self.rendezvous_indices = set() self.rendezvous_indices = set()
self.state = MissionState.IDLE self.state = MissionState.IDLE
print("任務停止") _log("INFO", "任務已停止")
# ------------------------------------------------------------------ 控制迴圈 # ------------------------------------------------------------------ 控制迴圈
@@ -170,7 +187,7 @@ class MissionExecutor(QObject):
""" """
now = time.monotonic() now = time.monotonic()
# ---- Phase 1: 到達判定 ---- # ---- Phase 1: 到達判定(hover-stable) ----
for task in self.tasks.values(): for task in self.tasks.values():
if task.done or task.status == TaskStatus.FALLBACK_LOITER: if task.done or task.status == TaskStatus.FALLBACK_LOITER:
continue continue
@@ -188,16 +205,38 @@ class MissionExecutor(QObject):
tgt_lat, tgt_lon, _ = target tgt_lat, tgt_lon, _ = target
distance = _haversine(gps['lat'], gps['lon'], tgt_lat, tgt_lon) distance = _haversine(gps['lat'], gps['lon'], tgt_lat, tgt_lon)
if distance >= self.arrival_radius:
# hover-stable 判定:
# - 首次進入 radius → 記錄時間
# - 在 radius * hysteresis 內持續 → 保留計時
# - 離開 hysteresis → 重置
# - 在 radius 內穩定 hover_stable_sec 秒 → 視為到達
hysteresis_radius = self.arrival_radius * self.hysteresis_factor
if distance < self.arrival_radius:
if task.entered_radius_at == 0.0:
task.entered_radius_at = now
stable_for = now - task.entered_radius_at
if stable_for < self.hover_stable_sec:
self._maybe_log_progress(task, now, distance, stable_for)
continue
elif distance < hysteresis_radius and task.entered_radius_at > 0.0:
# 還在 hysteresis 內,計時不重置(容忍 GPS 抖動跨越邊界)
self._maybe_log_progress(task, now, distance, now - task.entered_radius_at)
continue
else:
# 離開 hysteresis:重置計時,繼續等待到達
task.entered_radius_at = 0.0
self._maybe_log_progress(task, now, distance, 0.0)
continue continue
# 到達當前 wp_index # 到達當前 wp_index
task.entered_radius_at = 0.0 # 重置給下個 wp 用
if (task.wp_index in self.rendezvous_indices if (task.wp_index in self.rendezvous_indices
and task.wp_index < task.total_waypoints - 1): and task.wp_index < task.total_waypoints - 1):
# rendezvous 點 → 不推進,進入 barrier 等待 # rendezvous 點 → 不推進,進入 barrier 等待
task.status = TaskStatus.WAITING_AT_BARRIER task.status = TaskStatus.WAITING_AT_BARRIER
task.waiting_since = now task.waiting_since = now
print(f" {task.drone_id} → 抵達 barrier WP {task.wp_index},等待同伴") _log("INFO", f"{task.drone_id} 抵達 barrier WP {task.wp_index},等待同伴")
self.task_status_changed.emit( self.task_status_changed.emit(
task.drone_id, task.status.value, task.drone_id, task.status.value,
f"waiting at WP {task.wp_index}" f"waiting at WP {task.wp_index}"
@@ -236,26 +275,49 @@ class MissionExecutor(QObject):
self._timer.stop() self._timer.stop()
self.state = MissionState.IDLE self.state = MissionState.IDLE
self.mission_completed.emit() self.mission_completed.emit()
print("===== 任務全部完成 =====") _log("INFO", "任務全部完成")
def _maybe_log_progress(self, task, now, distance, stable_for):
"""週期性印出 drone 與當前 wp 的距離,方便實飛 debug 卡點問題。"""
if now - task.last_log_at < self.progress_log_interval_sec:
return
task.last_log_at = now
if stable_for > 0:
stable_info = f", 在內側 {stable_for:.1f}s/{self.hover_stable_sec:.1f}s"
else:
stable_info = ""
_log(
"INFO",
f"{task.drone_id} → WP {task.wp_index}: "
f"距離 {distance:.1f}m (半徑 {self.arrival_radius:.1f}m{stable_info})",
)
def _advance_waypoint(self, task, arrived_distance): def _advance_waypoint(self, task, arrived_distance):
"""把 task 推進一個航點,重置發送旗標。不處理 barrier 邏輯。""" """把 task 推進一個航點,重置發送旗標。不處理 barrier 邏輯。"""
task.wp_index += 1 task.wp_index += 1
task.sent_current_wp = False task.sent_current_wp = False
task.fail_count = 0 task.fail_count = 0
task.entered_radius_at = 0.0
task.last_log_at = 0.0
if task.wp_index >= task.total_waypoints: if task.wp_index >= task.total_waypoints:
task.done = True task.done = True
self.drone_waypoint_reached.emit( self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints task.drone_id, task.wp_index, task.total_waypoints
) )
print(f" {task.drone_id} → DONE " _log(
f"({task.total_waypoints}/{task.total_waypoints})") "INFO",
f"{task.drone_id} 已完成所有航點 "
f"({task.total_waypoints}/{task.total_waypoints})",
)
return return
self.drone_waypoint_reached.emit( self.drone_waypoint_reached.emit(
task.drone_id, task.wp_index, task.total_waypoints task.drone_id, task.wp_index, task.total_waypoints
) )
print(f" {task.drone_id} → WP {task.wp_index}/{task.total_waypoints} " _log(
f"(到達距離: {arrived_distance:.1f}m)") "INFO",
f"{task.drone_id} 前往 WP {task.wp_index}/{task.total_waypoints} "
f"(到達距離: {arrived_distance:.1f}m)",
)
def _check_barriers(self, now): def _check_barriers(self, now):
"""檢查每個有人在等的 barrier 能不能釋放。""" """檢查每個有人在等的 barrier 能不能釋放。"""
@@ -289,14 +351,13 @@ class MissionExecutor(QObject):
oldest_wait = min(t.waiting_since for t in waiting_tasks) oldest_wait = min(t.waiting_since for t in waiting_tasks)
if now - oldest_wait >= self.barrier_timeout_sec: if now - oldest_wait >= self.barrier_timeout_sec:
force_reason = f"timeout {self.barrier_timeout_sec:.0f}s" force_reason = f"timeout {self.barrier_timeout_sec:.0f}s"
print(f"⚠️ barrier WP {barrier_idx} {force_reason},強制放行") _log("WARN", f"barrier WP {barrier_idx} {force_reason},強制放行")
else: else:
continue # 還沒到齊、也還沒 timeout → 繼續等 continue # 還沒到齊、也還沒 timeout → 繼續等
# 釋放:把所有在此 barrier 等待的機一起推進 # 釋放:把所有在此 barrier 等待的機一起推進
tag = "全員到齊" if force_reason is None else force_reason tag = "全員到齊" if force_reason is None else force_reason
print(f"✅ barrier WP {barrier_idx} 釋放 ({tag})," _log("INFO", f"barrier WP {barrier_idx} 已釋放 ({tag}),推進 {len(waiting_tasks)} 架")
f"推進 {len(waiting_tasks)} 架")
for task in waiting_tasks: for task in waiting_tasks:
task.status = TaskStatus.NORMAL task.status = TaskStatus.NORMAL
msg = f"barrier WP {barrier_idx} released ({tag})" msg = f"barrier WP {barrier_idx} released ({tag})"
@@ -324,7 +385,7 @@ class MissionExecutor(QObject):
return return
task.fail_count += 1 task.fail_count += 1
print(f"⚠️ {drone_id} 發送失敗 {task.fail_count}/{self.MAX_RETRY}: {message}") _log("WARN", f"{drone_id} 發送失敗 {task.fail_count}/{self.MAX_RETRY}: {message}")
if task.fail_count < self.MAX_RETRY: if task.fail_count < self.MAX_RETRY:
task.status = TaskStatus.RETRYING task.status = TaskStatus.RETRYING
@@ -339,13 +400,13 @@ class MissionExecutor(QObject):
drone_id, task.status.value, drone_id, task.status.value,
f"fallback LOITER after {self.MAX_RETRY} fails: {message}" f"fallback LOITER after {self.MAX_RETRY} fails: {message}"
) )
print(f"❌ {drone_id} 連續失敗 {self.MAX_RETRY} 次 → 切換 LOITER") _log("ERROR", f"{drone_id} 連續失敗 {self.MAX_RETRY} 次,切換至 LOITER")
self._fallback_to_loiter(drone_id) self._fallback_to_loiter(drone_id)
def _fallback_to_loiter(self, drone_id): def _fallback_to_loiter(self, drone_id):
"""用 monitor.set_mode 切 LOITER。set_mode 是 coroutine,透過 event loop 派送。""" """用 monitor.set_mode 切 LOITER。set_mode 是 coroutine,透過 event loop 派送。"""
if self.monitor is None: if self.monitor is None:
print(f"⚠️ 無 monitor,無法將 {drone_id} 切到 LOITER") _log("WARN", f"無 monitor,無法將 {drone_id} 切換至 LOITER")
return return
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
+110 -26
View File
@@ -13,16 +13,34 @@ from mission_executor import MissionExecutor, MissionState
# 群組顏色(循環使用) # 群組顏色(循環使用)
GROUP_COLORS = [ GROUP_COLORS = [
'#4A9EFF', # 藍 "#E53935", # Deep Red
'#FF8C42', # 橘 "#FB8C00", # Deep Orange
'#56C87A', # 綠 "#FDD835", # Deep Yellow
'#E85D75', # 紅 "#43A047", # Deep Green
'#B07CED', # 紫 "#1E88E5", # Deep Blue
'#F5C542', # 黃 "#8E24AA", # Deep Purple
'#42C9C9', # 青 "#D81B60", # Deep Pink
'#FF6B9D', # 粉
] ]
DEFAULT_MISSION_PARAM_VALUES = {
'spacing': '5.0',
'base_altitude': '10.0',
'altitude_diff': '2.0',
'radius': '10.0',
'altitude': '10.0',
'start_angle': '0',
'lateral_offset': '3.0',
'longitudinal_spacing': '5.0',
'line_spacing': '5.0',
# 執行參數(所有任務類型共用)
'arrival_radius': '4.0', # 到達半徑 (m),實飛建議 3~5,SITL 可調小到 2
'hover_stable_sec': '2.0', # 在 radius 內穩定停留多久才視為到達 (s)
}
# 執行參數的 key 集合(所有 mission type 共用,不隨類型切換)
EXECUTION_PARAM_KEYS = ('arrival_radius', 'hover_stable_sec')
class MissionGroup: class MissionGroup:
"""單一任務群組的資料""" """單一任務群組的資料"""
@@ -144,6 +162,7 @@ class GroupPanel(QWidget):
mode_change_requested = pyqtSignal(str, str) # group_id, mode mode_change_requested = pyqtSignal(str, str) # group_id, mode
arm_requested = pyqtSignal(str) # group_id arm_requested = pyqtSignal(str) # group_id
takeoff_requested = pyqtSignal(str, float) # group_id, altitude takeoff_requested = pyqtSignal(str, float) # group_id, altitude
reboot_requested = pyqtSignal(str) # group_id
box_select_requested = pyqtSignal(str) # group_id — 框選直接分配 box_select_requested = pyqtSignal(str) # group_id — 框選直接分配
select_all_requested = pyqtSignal(str) # group_id — 全選直接分配 select_all_requested = pyqtSignal(str) # group_id — 全選直接分配
clear_group_requested = pyqtSignal(str) # group_id — 清除分組 clear_group_requested = pyqtSignal(str) # group_id — 清除分組
@@ -157,10 +176,13 @@ class GroupPanel(QWidget):
QPushButton:disabled {{ background-color: #444; color: #666; }} QPushButton:disabled {{ background-color: #444; color: #666; }}
""" """
def __init__(self, group: MissionGroup, parent=None): def __init__(self, group: MissionGroup, parent=None, default_params=None):
super().__init__(parent) super().__init__(parent)
self.group = group self.group = group
self.all_btn_ref = None # 保存全選按鈕的參考 self.all_btn_ref = None # 保存全選按鈕的參考
self.default_params = dict(DEFAULT_MISSION_PARAM_VALUES)
if default_params:
self.default_params.update(default_params)
self._build_ui() self._build_ui()
def _make_sep(self): def _make_sep(self):
@@ -355,6 +377,23 @@ class GroupPanel(QWidget):
right.addStretch() right.addStretch()
# ============================
# 飛控操作
# ============================
reboot_col = QVBoxLayout()
reboot_col.setSpacing(3)
reboot_title = QLabel("飛控操作")
reboot_title.setStyleSheet(TITLE)
reboot_col.addWidget(reboot_title)
reboot_btn = QPushButton("重啟飛控")
reboot_btn.setStyleSheet(BTN.format(bg='#8A3A3A', fg='#FFF', hover='#A84646'))
reboot_btn.clicked.connect(
lambda: self.reboot_requested.emit(self.group.group_id))
reboot_col.addWidget(reboot_btn)
reboot_col.addStretch()
# ============================ # ============================
# 第四欄:任務參數 # 第四欄:任務參數
# ============================ # ============================
@@ -372,26 +411,32 @@ class GroupPanel(QWidget):
# 每種任務類型的參數定義: (key, label, default_value) # 每種任務類型的參數定義: (key, label, default_value)
self._param_defs = { self._param_defs = {
'M_FORMATION': [ 'M_FORMATION': [
('spacing', '間距 (m)', '5.0'), ('spacing', '間距 (m)', self.default_params['spacing']),
('base_altitude', '基準高度 (m)', '10.0'), ('base_altitude', '基準高度 (m)', self.default_params['base_altitude']),
('altitude_diff', '高低差 (m)', '2.0'), ('altitude_diff', '高低差 (m)', self.default_params['altitude_diff']),
], ],
'CIRCLE_FORMATION': [ 'CIRCLE_FORMATION': [
('radius', '半徑 (m)', '10.0'), ('radius', '半徑 (m)', self.default_params['radius']),
('altitude', '高度 (m)', '10.0'), ('altitude', '高度 (m)', self.default_params['altitude']),
('start_angle', '起始角 (°)', '0'), ('start_angle', '起始角 (°)', self.default_params['start_angle']),
], ],
'LEADER_FOLLOWER': [ 'LEADER_FOLLOWER': [
('lateral_offset', '橫向偏移 (m)', '3.0'), ('lateral_offset', '橫向偏移 (m)', self.default_params['lateral_offset']),
('longitudinal_spacing', '縱向間距 (m)', '5.0'), ('longitudinal_spacing', '縱向間距 (m)', self.default_params['longitudinal_spacing']),
('altitude', '高度 (m)', '10.0'), ('altitude', '高度 (m)', self.default_params['altitude']),
], ],
'GRID_SWEEP': [ 'GRID_SWEEP': [
('line_spacing', '掃描線距 (m)', '5.0'), ('line_spacing', '掃描線距 (m)', self.default_params['line_spacing']),
('altitude', '高度 (m)', '10.0'), ('altitude', '高度 (m)', self.default_params['altitude']),
], ],
} }
# 所有 mission type 共用的執行參數(不隨類型切換隱藏)
self._exec_param_defs = [
('arrival_radius', '到達半徑 (m)', self.default_params['arrival_radius']),
('hover_stable_sec', '停留判定 (s)', self.default_params['hover_stable_sec']),
]
# 建立所有參數列的 widget(先全部建好,切換時顯示/隱藏) # 建立所有參數列的 widget(先全部建好,切換時顯示/隱藏)
self._param_widgets = {} # key → (label_widget, input_widget) self._param_widgets = {} # key → (label_widget, input_widget)
self._param_rows = [] # 所有 row layout 對應的 container widget self._param_rows = [] # 所有 row layout 對應的 container widget
@@ -415,6 +460,23 @@ class GroupPanel(QWidget):
self._param_widgets[key] = (row_w, inp) self._param_widgets[key] = (row_w, inp)
self._param_rows.append(row_w) self._param_rows.append(row_w)
# 執行參數列(所有 mission type 共用,永遠顯示)
for key, label_text, default in self._exec_param_defs:
row_w = QWidget()
row_l = QHBoxLayout(row_w)
row_l.setContentsMargins(0, 0, 0, 0)
row_l.setSpacing(3)
lbl = QLabel(label_text)
lbl.setStyleSheet(LBL)
inp = QLineEdit(default)
inp.setStyleSheet(INPUT)
inp.setFixedWidth(50)
row_l.addWidget(lbl, 1)
row_l.addWidget(inp)
param_col.addWidget(row_w)
self._param_widgets[key] = (row_w, inp)
self._param_rows.append(row_w)
# LEADER_FOLLOWER 專用:領隊下拉選單 # LEADER_FOLLOWER 專用:領隊下拉選單
self._leader_row = QWidget() self._leader_row = QWidget()
leader_layout = QHBoxLayout(self._leader_row) leader_layout = QHBoxLayout(self._leader_row)
@@ -438,7 +500,7 @@ class GroupPanel(QWidget):
# 當任務類型切換時更新參數顯示 # 當任務類型切換時更新參數顯示
self.type_combo.currentTextChanged.connect(self._update_param_visibility) self.type_combo.currentTextChanged.connect(self._update_param_visibility)
# ── 組裝四欄:控制指令 > 任務規劃 > 任務參數 > 選取與分組 ── # ── 組裝欄位:控制指令 > 任務規劃 > 任務參數 > 選取與分組 > 飛控操作 ──
# 使用伸展因子 0 讓列根據內容自動調整寬度,而不是均等分配 # 使用伸展因子 0 讓列根據內容自動調整寬度,而不是均等分配
cols.addLayout(left, 0) cols.addLayout(left, 0)
cols.addWidget(self._make_sep()) cols.addWidget(self._make_sep())
@@ -447,7 +509,9 @@ class GroupPanel(QWidget):
cols.addLayout(param_col, 0) cols.addLayout(param_col, 0)
cols.addWidget(self._make_sep()) cols.addWidget(self._make_sep())
cols.addLayout(right, 0) cols.addLayout(right, 0)
cols.addStretch() # 填充剩餘空間,使四列置左 cols.addWidget(self._make_sep())
cols.addLayout(reboot_col, 0)
cols.addStretch() # 填充剩餘空間,使欄位置左
layout.addLayout(cols) layout.addLayout(cols)
@@ -512,15 +576,17 @@ class GroupPanel(QWidget):
self.delete_group_btn.setEnabled(enabled) self.delete_group_btn.setEnabled(enabled)
def _update_param_visibility(self, _=None): def _update_param_visibility(self, _=None):
"""根據當前任務類型,顯示/隱藏對應的參數列""" """根據當前任務類型,顯示/隱藏對應的參數列。執行參數永遠顯示。"""
mission_type = self.type_combo.currentText() mission_type = self.type_combo.currentText()
visible_keys = {d[0] for d in self._param_defs.get(mission_type, [])} mission_keys = {d[0] for d in self._param_defs.get(mission_type, [])}
exec_keys = {d[0] for d in self._exec_param_defs}
visible_keys = mission_keys | exec_keys
for key, (row_w, _inp) in self._param_widgets.items(): for key, (row_w, _inp) in self._param_widgets.items():
row_w.setVisible(key in visible_keys) row_w.setVisible(key in visible_keys)
self._leader_row.setVisible(mission_type == 'LEADER_FOLLOWER') self._leader_row.setVisible(mission_type == 'LEADER_FOLLOWER')
def get_mission_params(self): def get_mission_params(self):
"""讀取當前顯示的參數值,回傳 dict""" """讀取當前任務類型的規劃參數,回傳 dict(不含執行參數)"""
mission_type = self.type_combo.currentText() mission_type = self.type_combo.currentText()
params = {} params = {}
for key, _label, default in self._param_defs.get(mission_type, []): for key, _label, default in self._param_defs.get(mission_type, []):
@@ -532,6 +598,25 @@ class GroupPanel(QWidget):
params[key] = float(default) params[key] = float(default)
return params return params
def get_execution_params(self):
"""讀取執行參數(arrival_radius, hover_stable_sec 等),回傳 dict"""
params = {}
for key, _label, default in self._exec_param_defs:
if key in self._param_widgets:
_row_w, inp = self._param_widgets[key]
try:
params[key] = float(inp.text())
except ValueError:
params[key] = float(default)
return params
def set_param_value(self, key, value):
"""更新指定參數欄位的文字值。"""
if key not in self._param_widgets:
return
_row_w, inp = self._param_widgets[key]
inp.setText(str(value))
def update_mission_info(self, center_lat, center_lon, target_lat, target_lon): def update_mission_info(self, center_lat, center_lon, target_lat, target_lon):
"""更新中心點 / 目標點顯示""" """更新中心點 / 目標點顯示"""
info_style = f"color: {self.group.color}; font-size: 11px; font-weight: bold;" info_style = f"color: {self.group.color}; font-size: 11px; font-weight: bold;"
@@ -553,4 +638,3 @@ class GroupPanel(QWidget):
except ValueError: except ValueError:
alt = 10.0 alt = 10.0
self.takeoff_requested.emit(self.group.group_id, alt) self.takeoff_requested.emit(self.group.group_id, alt)
+48 -201
View File
@@ -83,10 +83,11 @@ class FormationPlanner:
center_lat = sum(pos[0] for pos in drone_gps_positions) / len(drone_gps_positions) center_lat = sum(pos[0] for pos in drone_gps_positions) / len(drone_gps_positions)
center_lon = sum(pos[1] for pos in drone_gps_positions) / len(drone_gps_positions) center_lon = sum(pos[1] for pos in drone_gps_positions) / len(drone_gps_positions)
center_alt = sum(pos[2] for pos in drone_gps_positions) / len(drone_gps_positions)
self.current_origin = (center_lat, center_lon, center_alt) # origin_alt = 0:送給飛控的高度透過 MAV_FRAME_GLOBAL_RELATIVE_ALT_INT
self.converter = CoordinateConverter(center_lat, center_lon, center_alt) # 直接就是「相對 home 高度」,不需要加 GPS 橢球高度偏移
self.current_origin = (center_lat, center_lon, 0.0)
self.converter = CoordinateConverter(center_lat, center_lon, 0.0)
drone_local = [self.converter.gps_to_local(*pos) for pos in drone_gps_positions] drone_local = [self.converter.gps_to_local(*pos) for pos in drone_gps_positions]
target_local = self.converter.gps_to_local(*target_gps) target_local = self.converter.gps_to_local(*target_gps)
@@ -192,11 +193,13 @@ class FormationPlanner:
final_y = center_y + radius * math.sin(angle_rad) final_y = center_y + radius * math.sin(angle_rad)
final_z = altitude final_z = altitude
current_x, current_y, current_z = drone_positions[i] current_x, current_y, _current_z = drone_positions[i]
# stage1 高度直接用 final_z:先爬升到目標高度再水平集合,
# 避免用 current_z 做中點插值(current_z 在實飛時可能是 AMSL 而非相對 home,會導致衝高)
stage1_positions.append(( stage1_positions.append((
current_x + (final_x - current_x) * 0.5, current_x + (final_x - current_x) * 0.5,
current_y + (final_y - current_y) * 0.5, current_y + (final_y - current_y) * 0.5,
current_z + (final_z - current_z) * 0.5 final_z,
)) ))
stage2_positions.append((final_x, final_y, final_z)) stage2_positions.append((final_x, final_y, final_z))
@@ -206,29 +209,17 @@ class FormationPlanner:
def _calculate_leader_follower(self, drone_positions, route_wps_local, params): def _calculate_leader_follower(self, drone_positions, route_wps_local, params):
""" """
路徑跟隨編隊 — 虛擬領隊 / 弧長參數化版 (virtual leader / arc-length) 路徑跟隨編隊 — 虛擬領隊 / 弧長參數化
核心想法: center_path = 使用者畫的路徑點(直線段連接,不插弧),
- center_path 建好後計算每個 sample 的累積弧長 s 每架 drone 沿弧長 s 參數化做隊形偏移:
- 每架 drone 給 (lat_k, lon_k) 的隊形偏移 s_follower = s_leader - lon_k
- 把 leader 想成沿 s 參數化的點,follower k 的目標 s = s_leader - lon_k position = lookup(s_follower) + lat_k × perp(tangent)
- follower k 第 i 個 waypoint = lookup(s_list[i] - lon_k) + lat_k × perp(tangent) s 超出 path 範圍時外推(不 clip),確保每架 drone 起終點不重疊。
- 超過 path 起點/終點以 clip 處理,避免倒退或衝出
解決前版「空間偏移」的三個 bug: Rank = 輸入順序(GUI 選取順序),drone_positions[0] = leader。
1. 起點暴衝:s<0 clip → follower 起點就是 start 加橫向偏移,不往後倒退 隊形 (俯視):
2. 弧段角度爆炸:lon 不再換算成弧度,直接沿 polyline lookup D0 (lat=-L, lon=0) ← leader
3. straight/arc 切換不連續:統一走 polyline 線性內插與 segment 切線
Rank 定序規則:
**以 drone_positions 的輸入順序為準**(= GUI 選取順序)。
drone_positions[0] = rank 0 = leader (lon=0),
drone_positions[1] = rank 1 (lon=5),依此類推。
刻意「不」用位置投影自動排序,避免浮點噪音造成 run-to-run
leader 漂移,以確保整個 mission 像軍隊縱隊那樣順序固定。
隊形 (俯視, 以路徑行進方向為前):
D0 (lat=-L, lon=0) ← front-right (leader)
D1 (lat=+L, lon=5) D1 (lat=+L, lon=5)
D2 (lat=-L, lon=10) D2 (lat=-L, lon=10)
D3 (lat=+L, lon=15) D3 (lat=+L, lon=15)
@@ -237,29 +228,16 @@ class FormationPlanner:
lateral_offset = params.get('lateral_offset', 3.0) lateral_offset = params.get('lateral_offset', 3.0)
longitudinal_spacing = params.get('longitudinal_spacing', 5.0) longitudinal_spacing = params.get('longitudinal_spacing', 5.0)
altitude = params.get('altitude', self.base_altitude) altitude = params.get('altitude', self.base_altitude)
min_inner_radius = params.get('min_inner_radius', 3.0)
arc_resolution = params.get('arc_resolution', 12)
sharp_turn_cos = params.get('sharp_turn_cos_threshold', 0.0) # cos(90°)=0
# Step 1: 建立中心路徑(含圓弧、銳角單點;每點帶累積 s) # Step 1: 建立中心路徑(直線段連接,每個中間航點設 barrier)
center_path, barrier_indices = self._build_center_path( center_path, barrier_indices = self._build_center_path(route_wps_local)
route_wps_local,
formation_max_lateral=lateral_offset,
min_inner_radius=min_inner_radius,
arc_resolution=arc_resolution,
sharp_turn_cos_threshold=sharp_turn_cos,
)
s_list = [pt['s'] for pt in center_path] s_list = [pt['s'] for pt in center_path]
s_max = s_list[-1] s_max = s_list[-1]
n_pts = len(center_path) n_pts = len(center_path)
def lookup(s_target): def lookup(s_target):
""" """在 center path 上以弧長 s 回傳 (x, y, tangent_dir)。超出範圍時沿端點方向外推。"""
在 center path 上以弧長 s 回傳 (x, y, tangent_dir)。
s<0 → clip 到 start;s>s_max → clip 到 end。
tangent 方向取當前 polyline segment 的切線,避免對不連續的 dir 做插值。
"""
if n_pts == 1: if n_pts == 1:
pt = center_path[0] pt = center_path[0]
return pt['x'], pt['y'], pt['dir'] return pt['x'], pt['y'], pt['dir']
@@ -270,7 +248,8 @@ class FormationPlanner:
sdx, sdy = b['x'] - a['x'], b['y'] - a['y'] sdx, sdy = b['x'] - a['x'], b['y'] - a['y']
slen = math.hypot(sdx, sdy) slen = math.hypot(sdx, sdy)
if slen > 1e-6: if slen > 1e-6:
return a['x'], a['y'], (sdx / slen, sdy / slen) dx, dy = sdx / slen, sdy / slen
return a['x'] + s_target * dx, a['y'] + s_target * dy, (dx, dy)
return a['x'], a['y'], a['dir'] return a['x'], a['y'], a['dir']
if s_target >= s_max: if s_target >= s_max:
@@ -279,7 +258,9 @@ class FormationPlanner:
sdx, sdy = b['x'] - a['x'], b['y'] - a['y'] sdx, sdy = b['x'] - a['x'], b['y'] - a['y']
slen = math.hypot(sdx, sdy) slen = math.hypot(sdx, sdy)
if slen > 1e-6: if slen > 1e-6:
return b['x'], b['y'], (sdx / slen, sdy / slen) dx, dy = sdx / slen, sdy / slen
overshoot = s_target - s_max
return b['x'] + overshoot * dx, b['y'] + overshoot * dy, (dx, dy)
return b['x'], b['y'], b['dir'] return b['x'], b['y'], b['dir']
# Binary search:s_list[lo] <= s_target < s_list[hi] # Binary search:s_list[lo] <= s_target < s_list[hi]
@@ -308,16 +289,6 @@ class FormationPlanner:
# 避免浮點噪音在投影值相近時翻轉 leader,保證 run-to-run 穩定 # 避免浮點噪音在投影值相近時翻轉 leader,保證 run-to-run 穩定
all_waypoints = [None] * N all_waypoints = [None] * N
# 預先算好路徑終點的切線(給收尾點用)
end = center_path[-1]
end_dx, end_dy = end['dir']
if n_pts >= 2:
a = center_path[-2]
sdx, sdy = end['x'] - a['x'], end['y'] - a['y']
slen = math.hypot(sdx, sdy)
if slen > 1e-6:
end_dx, end_dy = sdx / slen, sdy / slen
for rank in range(N): for rank in range(N):
lat_sign = -1 if rank % 2 == 0 else 1 lat_sign = -1 if rank % 2 == 0 else 1
lat = lat_sign * lateral_offset lat = lat_sign * lateral_offset
@@ -334,46 +305,16 @@ class FormationPlanner:
altitude, altitude,
)) ))
# 收尾:全員到 path 終點的 rank 位置(lon 歸零)
waypoints.append((
end['x'] + lat * (-end_dy),
end['y'] + lat * end_dx,
altitude,
))
all_waypoints[rank] = waypoints all_waypoints[rank] = waypoints
# barrier 索引仍指向 center_path 內 (range [0, n_pts-1]),
# 不觸及末尾收尾點,直接沿用即可
return all_waypoints, barrier_indices return all_waypoints, barrier_indices
def _build_center_path(self, waypoints, def _build_center_path(self, waypoints):
formation_max_lateral,
min_inner_radius,
arc_resolution,
sharp_turn_cos_threshold):
""" """
建立以「圓弧」連接直線段的中心路徑。 建立中心路徑 — 直接用路徑點連接,不插弧。
每個中間 waypoint 的處理: 每個中間航點設為 barrier(轉彎集合點),讓隊伍到齊後再推進。
1. 計算入向 u_in、出向 u_out 的夾角 β = acos(u_in·u_out) 飛控在 GUIDED 模式下自行處理直線飛行,不需要 GUI 預先平滑。
2. 若 cos β < sharp_turn_cos_threshold → 銳角:只插單點 (hover + 原地轉向),
barrier 放在該點,讓隊伍整體停下再繼續
3. 否則 → 平滑弧:
R_base = formation_max_lateral + min_inner_radius
d = R_base × tan(β/2),並以相鄰段長的 45% 為上限
R_actual = d / tan(β/2)
tangent points T_in = WP - u_in·d, T_out = WP + u_out·d
arc center = T_in + R_actual·sign·n_left,其中 sign=+1 左轉/CCW,-1 右轉/CW
arc 由 theta_in 到 theta_out 以 arc_resolution 等分
path 內容: [T_in(straight,u_in), arc_samples..., T_out(straight,u_out)]
barrier 放在 T_out (轉完彎後的集合點)
path 是一個 list[dict],每個 dict 至少含:
{'seg': 'straight' | 'arc',
'x': float, 'y': float,
'dir': (dx, dy)}
'arc' 類型額外含: 'arc_center', 'arc_R', 'arc_sign', 'theta'
Returns: Returns:
(path, barrier_indices) (path, barrier_indices)
@@ -385,129 +326,35 @@ class FormationPlanner:
'seg': 'straight', 'seg': 'straight',
'x': waypoints[0][0], 'y': waypoints[0][1], 'x': waypoints[0][0], 'y': waypoints[0][1],
'dir': (0.0, 1.0), 'dir': (0.0, 1.0),
's': 0.0,
}], [] }], []
segments = []
for i in range(num_wps - 1):
dx = waypoints[i + 1][0] - waypoints[i][0]
dy = waypoints[i + 1][1] - waypoints[i][1]
length = math.hypot(dx, dy)
if length < 0.01:
segments.append(((0.0, 1.0), length))
else:
segments.append(((dx / length, dy / length), length))
R_base = formation_max_lateral + min_inner_radius
path = [] path = []
barrier_indices = [] barrier_indices = []
# 起點 for i in range(num_wps):
path.append({ if i < num_wps - 1:
'seg': 'straight', dx = waypoints[i + 1][0] - waypoints[i][0]
'x': waypoints[0][0], 'y': waypoints[0][1], dy = waypoints[i + 1][1] - waypoints[i][1]
'dir': segments[0][0], else:
}) dx = waypoints[i][0] - waypoints[i - 1][0]
dy = waypoints[i][1] - waypoints[i - 1][1]
for i in range(1, num_wps - 1): length = math.hypot(dx, dy)
u_in, len_in = segments[i - 1] if length < 0.01:
u_out, len_out = segments[i] direction = (0.0, 1.0)
wp = waypoints[i] else:
direction = (dx / length, dy / length)
dot = u_in[0] * u_out[0] + u_in[1] * u_out[1] path.append({
dot = max(-1.0, min(1.0, dot)) 'seg': 'straight',
'x': waypoints[i][0], 'y': waypoints[i][1],
'dir': direction,
})
# 銳角:hover + 原地轉向 if 0 < i < num_wps - 1:
if dot < sharp_turn_cos_threshold:
avg_dx = u_in[0] + u_out[0]
avg_dy = u_in[1] + u_out[1]
avg_len = math.hypot(avg_dx, avg_dy)
if avg_len > 0.01:
avg_dir = (avg_dx / avg_len, avg_dy / avg_len)
else:
avg_dir = u_in
path.append({
'seg': 'straight',
'x': wp[0], 'y': wp[1],
'dir': avg_dir,
})
barrier_indices.append(len(path) - 1) barrier_indices.append(len(path) - 1)
continue
# 平滑弧
beta = math.acos(dot)
if beta < 1e-4:
# 幾乎直線,不插弧
path.append({
'seg': 'straight',
'x': wp[0], 'y': wp[1],
'dir': u_in,
})
continue
half_tan = math.tan(beta / 2.0)
d_ideal = R_base * half_tan
d_max = 0.45 * min(len_in, len_out)
d = min(d_ideal, d_max)
R_actual = d / half_tan
T_in = (wp[0] - u_in[0] * d, wp[1] - u_in[1] * d)
T_out = (wp[0] + u_out[0] * d, wp[1] + u_out[1] * d)
# +1 CCW (左轉) / -1 CW (右轉)
cross = u_in[0] * u_out[1] - u_in[1] * u_out[0]
sign = 1 if cross > 0 else -1
# 入向左法線
n_left = (-u_in[1], u_in[0])
center = (
T_in[0] + R_actual * sign * n_left[0],
T_in[1] + R_actual * sign * n_left[1],
)
theta_in = math.atan2(T_in[1] - center[1], T_in[0] - center[0])
# T_in (straight, 方向 u_in)
path.append({
'seg': 'straight',
'x': T_in[0], 'y': T_in[1],
'dir': u_in,
})
# 弧段採樣 k=1..arc_resolution-1(不含兩端)
for k in range(1, arc_resolution):
t = k / arc_resolution
theta = theta_in + sign * beta * t
px = center[0] + R_actual * math.cos(theta)
py = center[1] + R_actual * math.sin(theta)
# 切線: sign × (-sin θ, cos θ)
tx = sign * (-math.sin(theta))
ty = sign * math.cos(theta)
path.append({
'seg': 'arc',
'x': px, 'y': py,
'dir': (tx, ty),
'arc_center': center,
'arc_R': R_actual,
'arc_sign': sign,
'theta': theta,
})
# T_out (straight, 方向 u_out),barrier 放這
path.append({
'seg': 'straight',
'x': T_out[0], 'y': T_out[1],
'dir': u_out,
})
barrier_indices.append(len(path) - 1)
# 終點
path.append({
'seg': 'straight',
'x': waypoints[-1][0], 'y': waypoints[-1][1],
'dir': segments[-1][0],
})
# 後處理:為每個 sample 加上累積 polyline 弧長 s
path[0]['s'] = 0.0 path[0]['s'] = 0.0
for i in range(1, len(path)): for i in range(1, len(path)):
prev = path[i - 1] prev = path[i - 1]
+21 -16
View File
@@ -6,8 +6,9 @@ class OverviewTable(QTableWidget):
"""總覽表格,顯示所有無人機的狀態資訊""" """總覽表格,顯示所有無人機的狀態資訊"""
# 默認的資訊類型和映射 # 默認的資訊類型和映射
DEFAULT_INFO_TYPES = ["模式", "ARM", "電壓", "經度", "緯度", "高度", "XY位置", "XY速度", "地速", "航向", DEFAULT_INFO_TYPES = ["模式", "ARM", "電壓", "經度", "緯度", "FixType", "衛星數", "EPH", "EPV",
"空速", "油門", "海拔高度", "爬升率", "Roll", "Pitch", "Yaw", "丟包", "延遲"] "高度", "XY位置", "XY速度", "地速", "航向", "空速", "油門", "海拔高度",
"爬升率", "Roll", "Pitch", "Yaw", "丟包", "延遲"]
DEFAULT_INFO_TYPE_MAP = { DEFAULT_INFO_TYPE_MAP = {
"mode": 0, "mode": 0,
@@ -15,20 +16,24 @@ class OverviewTable(QTableWidget):
"battery": 2, "battery": 2,
"longitude": 3, "longitude": 3,
"latitude": 4, "latitude": 4,
"altitude": 5, "fix_type": 5,
"local": 6, "satellites_visible": 6,
"velocity": 7, "eph": 7,
"groundspeed": 8, "epv": 8,
"heading": 9, "altitude": 9,
"airspeed": 10, "local": 10,
"throttle": 11, "velocity": 11,
"hud_alt": 12, "groundspeed": 12,
"climb": 13, "heading": 13,
"roll": 14, "airspeed": 14,
"pitch": 15, "throttle": 15,
"yaw": 16, "hud_alt": 16,
"loss_rate": 17, "climb": 17,
"ping": 18 "roll": 18,
"pitch": 19,
"yaw": 20,
"loss_rate": 21,
"ping": 22
} }
def __init__(self, info_types=None, info_type_map=None, parent=None): def __init__(self, info_types=None, info_type_map=None, parent=None):
+7
View File
@@ -8,11 +8,18 @@ endif()
# find dependencies # find dependencies
find_package(ament_cmake REQUIRED) find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED) find_package(rosidl_default_generators REQUIRED)
find_package(std_msgs REQUIRED)
find_package(builtin_interfaces REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME} rosidl_generate_interfaces(${PROJECT_NAME}
"msg/AttitudeRaw.msg"
"msg/GnssRaw.msg"
"msg/SystemDiagnosticsRaw.msg"
"msg/ServiceAckResult.msg"
"srv/MavPing.srv" "srv/MavPing.srv"
"srv/MavCommandLong.srv" "srv/MavCommandLong.srv"
"srv/MavPositionTargetGlobalInt.srv" "srv/MavPositionTargetGlobalInt.srv"
DEPENDENCIES std_msgs builtin_interfaces
) )
ament_package() ament_package()
+9
View File
@@ -0,0 +1,9 @@
uint32 seq
builtin_interfaces/Time stamp
string frame_id
float32 roll
float32 pitch
float32 yaw
float32 rollspeed
float32 pitchspeed
float32 yawspeed
+8
View File
@@ -0,0 +1,8 @@
builtin_interfaces/Time stamp
float64 latitude
float64 longitude
float64 altitude
uint8 fix_type
uint8 satellites_visible
float32 eph
float32 epv
@@ -0,0 +1,43 @@
# ServiceAckResult.msg
# ACK code definition for command-like service responses.
# 這邊只是定義常數的檔案
#
# Design:
# - 0~10 : MAVLink MAV_RESULT compatible range (autopilot/firmware ACK) 大多不會用到 直接吃飛控回傳值
# - 50~89 : System-level ACK codes (local bridge/orchestrator status)
# - 90~99 : Unknown/reserved fallback
#
# Rule:
# - If FC ACK is received, return MAV_RESULT_* code.
# - If FC ACK is not reachable / local pipeline failed, return SYS_* code.
# ---- MAVLink MAV_RESULT (standard) ---- 詳細請自行去網路找 mavlink MAV_RESULT
uint8 MAV_RESULT_ACCEPTED=0 # 封包正常接受並執行
uint8 MAV_RESULT_TEMPORARILY_REJECTED=1 # 封包正常接受 但是飛控很忙 不執行 需要等待後重試
uint8 MAV_RESULT_DENIED=2 # 封包不正常 參數可能給錯了
uint8 MAV_RESULT_UNSUPPORTED=3 # 封包不正常 根本認不出是啥
uint8 MAV_RESULT_FAILED=4 # 封包正常接受 但是載具的狀態異常 不執行
uint8 MAV_RESULT_IN_PROGRESS=5
uint8 MAV_RESULT_CANCELLED=6
uint8 MAV_RESULT_COMMAND_LONG_ONLY=7
uint8 MAV_RESULT_COMMAND_INT_ONLY=8
uint8 MAV_RESULT_COMMAND_UNSUPPORTED_MAV_FRAME=9
uint8 MAV_RESULT_NOT_IN_CONTROL=10
# ---- System-level result codes (custom) ----
# 50 : 有接到預期的封包 但是訊息建議再確認
# 51 : some messages will not have obviously ack, so monitering feature message. however, we do NOT get the feature message. ex. 85/86 pair
#
# 52 : mavlink communication timeout 可能是通訊丟包了
# 53 : there already has a command being execution for this sysid, try later
# 54 : this sysid is not found
uint8 FCSYS_GET_EXPECTED_FEEDBACK=50
uint8 FCSYS_NO_EXPECTED_FEEDBACK=51
uint8 FCSYS_MAVLINK_TIMEOUT=52
uint8 FCSYS_BUSY=53
uint8 FCSYS_DEVICE_NOT_FOUND=54
# ---- Fallback ----
uint8 FCSYS_UNKNOWN=90
@@ -0,0 +1,11 @@
builtin_interfaces/Time stamp
uint32 sensors_install_mask
uint32 sensors_enabled_mask
uint32 sensors_health_mask
uint16 mcu_load
uint16 bus_error_rate
uint16 bus_error_count
uint16 errors_count1
uint16 errors_count2
uint16 errors_count3
uint16 errors_count4
+5
View File
@@ -15,6 +15,11 @@
<test_depend>ament_lint_auto</test_depend> <test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend> <test_depend>ament_lint_common</test_depend>
<build_depend>std_msgs</build_depend>
<exec_depend>std_msgs</exec_depend>
<build_depend>builtin_interfaces</build_depend>
<exec_depend>builtin_interfaces</exec_depend>
<export> <export>
<build_type>ament_cmake</build_type> <build_type>ament_cmake</build_type>
</export> </export>
@@ -1,31 +0,0 @@
'''
單獨出來定義回傳碼的意義
若跟飛控韌體有正常的通訊 (無論是否被拒絕) 則以 mavlink 定義的結果回傳
若跟飛控韌體無法通訊 才使用這邊的錯誤代碼
所以要避開重疊的號碼
這邊備註 Mavlink 的 MAV_RESULT 參數意義
0 : 封包正常接受並執行
1 : 封包正常接受 但是正在忙不執行 需要等待後重試
4 : 封包正常接受 但是載具的狀態異常 不執行
2 : 封包不正常 參數可能給錯了
3 : 封包不正常 根本認不出是啥
'''
from enum import IntEnum
class serviceAckResult(IntEnum):
MAVLINK_TIMEOUT = 51 # mavlink communication timeout
MAVLINK_SEND_BUT_NO_EXP_STEAM = 30 # some messages will not have obviously ack, so monitering feature message. however, we do NOT get the feature message. ex. 85/86 pair
MAVLINK_BUSY = 53 # there already has a command being execution for this sysid, try later
MAVLINK_DEV_NOTFOUND = 54 # this sysid not fount
UNKNOWN = 90 # i did not capture this error, try read the logs
@@ -37,6 +37,7 @@
- *self.bridge* 存放 mavlink_bridge 實例 - *self.bridge* 存放 mavlink_bridge 實例
- *self.plumber* 存放 serial_manager 實例 - *self.plumber* 存放 serial_manager 實例
- *self.vehicle_registry* 存放 vehicle_registry 實例 - *self.vehicle_registry* 存放 vehicle_registry 實例
- *self.fc_ros_manager* 存放 fc_ros_manager 實例
- *self.panel_thread* 面板的執行緒 - *self.panel_thread* 面板的執行緒
- *self.panelState* 暫存面板與調配者互動的資料流動區 - *self.panelState* 暫存面板與調配者互動的資料流動區
@@ -176,13 +177,15 @@
這裡支持了所有 ROS2 框架的對應功能 包涵 這裡支持了所有 ROS2 框架的對應功能 包涵
1. 將載具的串流更新的狀態更新到對應的 topic 1. 將載具的串流更新的狀態更新到對應的 topic
2. 將地面站與載具互動 mavlink microservice 包裝起來提供 ros2 service 互動介面 2. 將地面站與載具互動 mavlink microservice 包裝起來提供 ros2 service 互動介面
3. 訂閱外部 RTCM 資料並轉發為 MAVLink GPS_RTCM_DATA 給所有載具
### **[Class]** fc_ros_manager ### **[Class]** fc_ros_manager
MAVLink ROS2 節點管理器 管理兩個獨立的 Node MAVLink ROS2 節點管理器 管理三個獨立的 Node
會開一個執行緒 讓兩個 Node 都跑在裡面 會開一個執行緒 讓三個 Node 都跑在裡面
然後用 spin_once 保持 Node 的活性 然後用 spin_once 保持 Node 的活性
- *self.status_publisher* 物件實例 - *self.status_publisher* 物件實例
- *self.command_service* 物件實例 - *self.command_service* 物件實例
- *self.rtcm_relay* 物件實例
- *self.spin_thread* 執行緒 - *self.spin_thread* 執行緒
--- ---
- *start()* 啟動自己 - *start()* 啟動自己
@@ -210,6 +213,20 @@
- *__init__()* 這邊登入要創建的 service 到 ros2 系統 - *__init__()* 這邊登入要創建的 service 到 ros2 系統
- *handle_XXX()* 這邊是實現 service 的具體方法 - *handle_XXX()* 這邊是實現 service 的具體方法
### **[Class]** RtcmRelay
訂閱 /fc_network/rtcm_in (mavros_msgs/msg/RTCM) 並轉發為 MAVLink GPS_RTCM_DATA
topic 的 QoS:BEST_EFFORT / KEEP_LAST depth=1 / VOLATILE(低延遲優先,丟舊包不排隊)
處理順序:過期丟棄(1s) → blake2s hash 去重 → 最短轉發間隔節流 → 分片轉發
分片:GPS_RTCM_DATA 每片 180 bytes,超過自動分片,最多 4 片 (720 bytes),超長直接丟棄
失敗語意:不做舊包重送,送失敗就放棄等下一包
- *self.counters* 觀測用計數器 (received/forwarded/dropped_stale/dropped_duplicate/dropped_throttle/dropped_oversize/no_route/send_fail)
- *self.clock_offset_sec* 與 RTCM 來源的時鐘偏移量(秒)
---
- *_rtcm_callback()* 收到 RTCM 封包時的處理流程
- *_send_rtcm()* 對單一載具編碼並送出 GPS_RTCM_DATA(含分片邏輯)
- *set_clock_offset()* 設定時鐘偏移量
- *get_counters()* 取得觀測計數器
# 開發記錄 # 開發記錄
@@ -224,7 +241,14 @@
8. 基礎載具流量觀測 8. 基礎載具流量觀測
9. 載具狀態收集與彙整 9. 載具狀態收集與彙整
10. a. ros2 topic 應用開發介面 (基礎框架) 10. a. ros2 topic 應用開發介面 (基礎框架)
- GNSS (alt lan fix_type epv eph)
- attitude position_ned (pitch yaw row local_position)
- battery
b. ros2 service 應用開發介面 (基礎框架) b. ros2 service 應用開發介面 (基礎框架)
c. RTCM 轉發 - 訂閱 RTCM topic 轉為 MAVLink GPS_RTCM_DATA 廣播給所有載具
11. ros2 topic service rtcm Node 重啟功能
12. 可以定義 ROS_DOMAIN_ID
### 待開發功能 ### 待開發功能
5-1. 建立 serial 連線 並可以對接收器下達AT指令 5-1. 建立 serial 連線 並可以對接收器下達AT指令
@@ -29,7 +29,7 @@ from .utils import acquireSerial, acquirePort
from .utils.acquirePort import find_available_port from .utils.acquirePort import find_available_port
logger = setup_logger(os.path.basename(__file__)) logger = setup_logger(os.path.basename(__file__))
PROJECT_VER = "v0.70" PROJECT_VER = "v1.20"
class PanelState: class PanelState:
def __init__(self): def __init__(self):
@@ -177,11 +177,18 @@ class ControlPanel:
MenuNode("New+", "新增 Serial 連接埠", action = "LIST_SERIAL_RES"), MenuNode("New+", "新增 Serial 連接埠", action = "LIST_SERIAL_RES"),
MenuNode("ListAll", "顯示並管理已連線的 Serial", action = "LIST_SERIAL_LINKS"), MenuNode("ListAll", "顯示並管理已連線的 Serial", action = "LIST_SERIAL_LINKS"),
]), ]),
MenuNode("Vehicles Insp.", "檢視已連線的遠端載具", action = "INSPECT_VEHICLES"), MenuNode("Vehicles Insp.", "檢視已連線的遠端載具", action = "SHOW_VEHICLES"),
MenuNode("Engineer Mode", "工程模式", children=[ MenuNode("Engineer Mode", "工程模式", children=[
MenuNode("Stop Manager", "停止 Mavlink 物件管理", "STOP_MANAGER"), MenuNode("Stop Manager", "停止 Mavlink_Object 管理", "STOP_MANAGER"),
MenuNode("Stop Bridge", "停止 Mavlink-ROS 橋接", "STOP_BRIDGE"), MenuNode("Stop Bridge", "停止 Mavlink-ROS 橋接", "STOP_BRIDGE"),
MenuNode("Stop Serial M.", "停止 Serial 端口轉接", "STOP_SERIAL_MANAGER"), MenuNode("Stop Serial M.", "停止 Serial 端口", "STOP_SERIAL_MANAGER"),
MenuNode("Edit ROS Nodes", "管理 ROS NODE", children=[
MenuNode("Reboot Topics", "重建 ROS Topics", "REBOOT_ROS_TOPICS"),
MenuNode("Reboot Services", "重建 ROS Services", "REBOOT_ROS_SERVICES"),
MenuNode("Reboot RTCM", "重建 RTK RTCM", "REBOOT_ROS_RTCM"),
MenuNode("Stop ROS Manager", "停止 ROS 功能", "STOP_ROS_MANAGER"),
MenuNode("Start ROS Manager", "啟動 ROS 功能", "START_ROS_MANAGER"),
]),
]), ]),
MenuNode("Shutdown", "關閉整個系統", children=[ MenuNode("Shutdown", "關閉整個系統", children=[
MenuNode("Return", "繼續運行", "BACK"), MenuNode("Return", "繼續運行", "BACK"),
@@ -384,6 +391,7 @@ class ControlPanel:
state.intoTERMINATION() state.intoTERMINATION()
pre_panel_shutdown() pre_panel_shutdown()
# related with -> UDP mavlink Object
elif selected.action == "TEXT_UDP_IP": elif selected.action == "TEXT_UDP_IP":
result = ControlPanel.input_dialog(stdscr, "請輸入監聽的 IP 位址: ") result = ControlPanel.input_dialog(stdscr, "請輸入監聽的 IP 位址: ")
if result is not None: if result is not None:
@@ -412,6 +420,8 @@ class ControlPanel:
# menu_stack.pop() # menu_stack.pop()
# idx_stack.pop() # idx_stack.pop()
# related with -> Serial Connection
elif selected.action == "TEXT_BAUD_SERIAL": elif selected.action == "TEXT_BAUD_SERIAL":
result = ControlPanel.input_dialog(stdscr, "請輸入 Baud Rate (e.g., 9600, 115200): ") result = ControlPanel.input_dialog(stdscr, "請輸入 Baud Rate (e.g., 9600, 115200): ")
if result is not None: if result is not None:
@@ -477,12 +487,7 @@ class ControlPanel:
menu_stack.pop() menu_stack.pop()
idx_stack.pop() idx_stack.pop()
elif selected.action == "LIST_MAV_OBJECT": # related with -> 列表選單
# 動態生成 mavlink_object 列表選單
created_list_menu = self.create_object_list_menu(state, page=0)
menu_stack.append(created_list_menu)
idx_stack.append(0)
elif selected.action in ("PREV_PAGE", "NEXT_PAGE"): elif selected.action in ("PREV_PAGE", "NEXT_PAGE"):
if hasattr(selected, 'page'): if hasattr(selected, 'page'):
current_list_menu = menu_stack[-1] current_list_menu = menu_stack[-1]
@@ -507,6 +512,13 @@ class ControlPanel:
menu_stack.append(created_list_menu) menu_stack.append(created_list_menu)
idx_stack.append(0) idx_stack.append(0)
# related with -> mavlink object list
elif selected.action == "LIST_MAV_OBJECT":
# 動態生成 mavlink_object 列表選單
created_list_menu = self.create_object_list_menu(state, page=0)
menu_stack.append(created_list_menu)
idx_stack.append(0)
elif selected.action == "INSPECT_MAV_OBJECT": elif selected.action == "INSPECT_MAV_OBJECT":
# 顯示物件詳細資訊 # 顯示物件詳細資訊
if hasattr(selected, 'socket_id'): if hasattr(selected, 'socket_id'):
@@ -551,6 +563,7 @@ class ControlPanel:
if target_id is not None: if target_id is not None:
cmd_q.put(("MAVOBJ_ADD_TARGET", selected.socket_id, target_id)) cmd_q.put(("MAVOBJ_ADD_TARGET", selected.socket_id, target_id))
# related with -> Engineer Mode
elif selected.action == "STOP_MANAGER": elif selected.action == "STOP_MANAGER":
if state.panel_status != "Engineer": if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time())) state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
@@ -568,8 +581,40 @@ class ControlPanel:
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time())) state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作 continue # 只有在工程模式下才能操作
cmd_q.put("SHUTDOWN_SERIAL_MANAGER") cmd_q.put("SHUTDOWN_SERIAL_MANAGER")
elif selected.action == "STOP_ROS_MANAGER":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put("STOP_ROS_MANAGER")
elif selected.action == "INSPECT_VEHICLES": elif selected.action == "START_ROS_MANAGER":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put("START_ROS_MANAGER")
elif selected.action == "REBOOT_ROS_TOPICS":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put(("RESTART_ROS_NODE", "status_publisher")) # 參照一下 mavlinkROS2Nodes.NODE_KEYS
elif selected.action == "REBOOT_ROS_SERVICES":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put(("RESTART_ROS_NODE", "command_service")) # 參照一下 mavlinkROS2Nodes.NODE_KEYS
elif selected.action == "REBOOT_ROS_RTCM":
if state.panel_status != "Engineer":
state.panel_info_msg_list.append(("Not in Engineer Mode.", time.time()))
continue # 只有在工程模式下才能操作
cmd_q.put(("RESTART_ROS_NODE", "rtcm_relay")) # 參照一下 mavlinkROS2Nodes.NODE_KEYS
# related with -> 進 bridge 的設備 列表
elif selected.action == "SHOW_VEHICLES":
# 進入載具檢視選單 # 進入載具檢視選單
cmd_q.put("UPDATE_VEHICLES_LIST") cmd_q.put("UPDATE_VEHICLES_LIST")
created_list_menu = self.create_vehicles_list_menu(state, page=0) created_list_menu = self.create_vehicles_list_menu(state, page=0)
@@ -1061,7 +1106,7 @@ class ControlPanel:
0: "HB", 1: "S_STAT", 2: "S_TIME", 24: "GPS_RAW", 27: "RAW_IMU", 0: "HB", 1: "S_STAT", 2: "S_TIME", 24: "GPS_RAW", 27: "RAW_IMU",
30: "ATT", 32: "LOC_POS", 33: "GLB_POS", 62: "NAV_CTL", 30: "ATT", 32: "LOC_POS", 33: "GLB_POS", 62: "NAV_CTL",
74: "VFR_HUD", 147: "BATT_ST", 136: "TERRAIN", 241: "VIBRAT", 74: "VFR_HUD", 147: "BATT_ST", 136: "TERRAIN", 241: "VIBRAT",
125: "POW_STA", 125: "POW_STA", 253: "STAT_TXT",
} }
# ardupilot mega # ardupilot mega
@@ -1145,14 +1190,16 @@ class Orchestrator:
self.fc_ros_manager = mros.ros2_manager self.fc_ros_manager = mros.ros2_manager
self.fc_ros_manager.initialize() self.fc_ros_manager.initialize()
self.fc_ros_manager.status_publisher.rate_controller.topic_intervals = { self.fc_ros_manager.status_publisher.rate_controller.topic_intervals = {
'position': 1.0, 'position_gnss': 0.2,
'position_ned': 1.0, 'position_ned': 0.5,
'attitude': 1.0, 'attitude': 0.1,
'velocity': 0.0, 'velocity': 0.0,
'battery': 1.0, 'battery': 1.0,
'vfr_hud': 1.0, 'vfr_hud': 1.0,
'mode': 0.0, 'mode': 0.0,
'summary': 1.0, 'summary': 1.0,
'sys_diags': 1.0,
'status_text': 1.0,
} }
def engageWholeSystem(self): def engageWholeSystem(self):
@@ -1173,6 +1220,8 @@ class Orchestrator:
def mainLoop(self): def mainLoop(self):
logger.info("Main orchestrator started <-") logger.info("Main orchestrator started <-")
self.ros2_retry_time = None
self.ros2_manual_stop = False
try: try:
# while not self.stop_evt.is_set(): # while not self.stop_evt.is_set():
while self.panel_thread.is_alive(): while self.panel_thread.is_alive():
@@ -1204,6 +1253,17 @@ class Orchestrator:
else: else:
self.panelState.ros2_manager_state = 'Stopped' self.panelState.ros2_manager_state = 'Stopped'
# 這邊做一個簡易的重啟 掛掉要等待一秒再重啟 避免過度頻繁 # 算是高技術債的方式構建 但是未來有需要在還吧 (煙
if not self.ros2_manual_stop:
# logger.info(f"mark A {self.ros2_retry_time}")
if self.ros2_retry_time:
if (time.time() - self.ros2_retry_time) > 1:
self.fc_ros_manager.start()
self.fc_ros_manager.schedule_restart_node("command_service")
self.ros2_retry_time = None
else:
self.ros2_retry_time = time.time()
# B. 更新載具列表(從 vehicle_registry 獲取) # B. 更新載具列表(從 vehicle_registry 獲取)
self._update_vehicles_list() self._update_vehicles_list()
@@ -1268,6 +1328,12 @@ class Orchestrator:
elif action == "UNREGISTER_VEHICLE": elif action == "UNREGISTER_VEHICLE":
sysid = cmd[1] sysid = cmd[1]
self.vehicle_registry.unregister(sysid) self.vehicle_registry.unregister(sysid)
elif action == "RESTART_ROS_NODE":
node_key = cmd[1]
if not self.fc_ros_manager.schedule_restart_node(node_key):
logger.warning(
f"RESTART_ROS_NODE failed: {node_key!r}"
)
elif cmd == "CREATE_UDP_INBOUND": elif cmd == "CREATE_UDP_INBOUND":
self.panelState.udp_info_temp["direction"] = "inbound" self.panelState.udp_info_temp["direction"] = "inbound"
@@ -1277,12 +1343,19 @@ class Orchestrator:
self.create_udp_object() self.create_udp_object()
elif cmd == "CREATE_SERIAL_PORT": elif cmd == "CREATE_SERIAL_PORT":
self.create_serial_port_object() self.create_serial_port_object()
elif cmd == "SHUTDOWN_BRIDGE": elif cmd == "SHUTDOWN_BRIDGE":
self.bridge.stop() self.bridge.stop()
elif cmd == "SHUTDOWN_MANAGER": elif cmd == "SHUTDOWN_MANAGER":
self.manager.shutdown() self.manager.shutdown()
elif cmd == "SHUTDOWN_SERIAL_MANAGER": elif cmd == "SHUTDOWN_SERIAL_MANAGER":
self.plumber.shutdown() self.plumber.shutdown()
elif cmd == "STOP_ROS_MANAGER":
self.fc_ros_manager.stop()
self.ros2_manual_stop = True
elif cmd == "START_ROS_MANAGER":
self.fc_ros_manager.start()
self.ros2_manual_stop = False
except queue.Empty: except queue.Empty:
pass pass
@@ -1571,10 +1644,10 @@ def main():
if mo.MODULE_VER != "1.50": if mo.MODULE_VER != "1.50":
print("Module Version Error! : mavlinkObkect") print("Module Version Error! : mavlinkObkect")
version_check = False version_check = False
if mros.MODULE_VER != "1.50": if mros.MODULE_VER != "2.50":
print("Module Version Error! : mavlinkROS2Nodes") print("Module Version Error! : mavlinkROS2Nodes")
version_check = False version_check = False
if mvv.MODULE_VER != "1.00": if mvv.MODULE_VER != "1.10":
print("Module Version Error! : mavlinkVehicleView") print("Module Version Error! : mavlinkVehicleView")
version_check = False version_check = False
if sm.MODULE_VER != "0.80": if sm.MODULE_VER != "0.80":
@@ -1622,4 +1695,7 @@ if __name__ == "__main__":
2025.04.13 2025.04.13
1. 加入各項模組的版本先驗 檢驗失敗就直接離開 1. 加入各項模組的版本先驗 檢驗失敗就直接離開
2025.05.28
1. 工程模式 新增 Node 重啟的功能
''' '''
@@ -51,7 +51,7 @@ from .mavlinkVehicleView import (
VehicleView, VehicleView,
VehicleComponent, VehicleComponent,
ComponentType, ComponentType,
ConnectionType StatusTextEntry,
) )
from .utils import RingBuffer, setup_logger from .utils import RingBuffer, setup_logger
@@ -108,12 +108,15 @@ class mavlink_bridge:
def _init_message_handlers(self): def _init_message_handlers(self):
"""初始化訊息處理器映射表,提高處理效率""" """初始化訊息處理器映射表,提高處理效率"""
self.message_handlers = { self.message_handlers = {
0: self._handle_heartbeat, # HEARTBEAT 0: self._handle_heartbeat, # HEARTBEAT
30: self._handle_attitude, # ATTITUDE 1: self._handle_vehicle_sys_status, # SYS_STATUS
32: self._handle_local_position, # LOCAL_POSITION_NED 24: self._handle_gps_raw_int, # GPS_RAW_INT
33: self._handle_global_position, # GLOBAL_POSITION_INT 30: self._handle_attitude, # ATTITUDE
74: self._handle_vfr_hud, # VFR_HUD 32: self._handle_local_position, # LOCAL_POSITION_NED
147: self._handle_battery_status, # BATTERY_STATUS 33: self._handle_global_position, # GLOBAL_POSITION_INT
74: self._handle_vfr_hud, # VFR_HUD
147: self._handle_battery_status, # BATTERY_STATUS
253: self._handle_status_text, # STATUSTEXT
} }
def start(self): def start(self):
@@ -230,6 +233,29 @@ class mavlink_bridge:
'vx': msg.vx, 'vy': msg.vy, 'vz': msg.vz 'vx': msg.vx, 'vy': msg.vy, 'vz': msg.vz
} }
def _handle_vehicle_sys_status(self, vehicle, component, msg, timestamp):
"""處理 SYS_STATUS 訊息 (msg_id: 1)"""
diag = component.status.sys_diag
diag.sensors_install_mask = msg.onboard_control_sensors_present
diag.sensors_enabled_mask = msg.onboard_control_sensors_enabled
diag.sensors_health_mask = msg.onboard_control_sensors_health
diag.mcuLoad = msg.load
diag.busErrorRate = msg.drop_rate_comm
diag.busErrorCount = msg.errors_comm
diag.errors_count1 = msg.errors_count1
diag.errors_count2 = msg.errors_count2
diag.errors_count3 = msg.errors_count3
diag.errors_count4 = msg.errors_count4
diag.timestamp = timestamp
def _handle_status_text(self, vehicle, component, msg, timestamp):
"""處理 STATUSTEXT 訊息 (msg_id: 253)"""
text = msg.text.rstrip('\x00') if msg.text else ''
if not text:
return
component.status.status_text_queue.append(
StatusTextEntry(text=text, severity=msg.severity, timestamp=timestamp)
)
def _handle_global_position(self, vehicle, component, msg, timestamp): def _handle_global_position(self, vehicle, component, msg, timestamp):
"""處理 GLOBAL_POSITION_INT 訊息 (msg_id: 33)""" """處理 GLOBAL_POSITION_INT 訊息 (msg_id: 33)"""
@@ -239,6 +265,14 @@ class mavlink_bridge:
component.status.position.relative_altitude = msg.relative_alt / 1000.0 # 轉換為公尺 component.status.position.relative_altitude = msg.relative_alt / 1000.0 # 轉換為公尺
component.status.position.timestamp = timestamp component.status.position.timestamp = timestamp
def _handle_gps_raw_int(self, vehicle, component, msg, timestamp):
"""處理 GPS_RAW_INT 訊息 (msg_id: 24)"""
component.status.gps.fix_type = msg.fix_type
component.status.gps.satellites_visible = msg.satellites_visible
component.status.gps.eph = None if msg.eph == 65535 else msg.eph
component.status.gps.epv = None if msg.epv == 65535 else msg.epv
component.status.gps.timestamp = timestamp
def _handle_vfr_hud(self, vehicle, component, msg, timestamp): def _handle_vfr_hud(self, vehicle, component, msg, timestamp):
"""處理 VFR_HUD 訊息 (msg_id: 74)""" """處理 VFR_HUD 訊息 (msg_id: 74)"""
component.status.vfr.airspeed = msg.airspeed component.status.vfr.airspeed = msg.airspeed
@@ -386,7 +420,9 @@ class mavlink_object:
self.MAVLink = mav_common.MAVLink(self.mavlinkPipeline, srcSystem=254, srcComponent=191) self.MAVLink = mav_common.MAVLink(self.mavlinkPipeline, srcSystem=254, srcComponent=191)
# 記錄訊息過濾類型 (可選) # 記錄訊息過濾類型 (可選)
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 # 0 HEARTBEAT, 1 SYS_STATUS, 24 GPS_RAW_INT, 30 ATTITUDE,
# 32 LOCAL_POSITION_NED, 33 GLOBAL_POSITION_INT, 74 VFR_HUD, 147 BATTERY_STATUS, 253 STATUSTEXT
self.bridge_msg_types = set([0, 1, 24, 30, 32, 33, 74, 147, 253])
self.return_msg_types = set([]) self.return_msg_types = set([])
# 轉發到別的 mavlink object 作為目標端口 的列表 # 轉發到別的 mavlink object 作為目標端口 的列表
@@ -823,5 +859,8 @@ if __name__ == '__main__':
1. async_io_manager.managed_objects 與 mavlink_object.mavlinkObjects 功能重複整合 保留 mavlink_object.mavlinkObjects 1. async_io_manager.managed_objects 與 mavlink_object.mavlinkObjects 功能重複整合 保留 mavlink_object.mavlinkObjects
2. async_io_manager 的 _stop_event 無效變數移除 2. async_io_manager 的 _stop_event 無效變數移除
2026年 06月 10日
1. 增加 SYS_STATUS 與 STATUSTEXT 訊息的處理機制
''' '''
@@ -1,11 +1,13 @@
""" """
MAVLink ROS2 Nodes MAVLink ROS2 Nodes
主要包含兩個獨立的 ROS2 Node : 主要包含三個獨立的 ROS2 Node :
1. VehicleStatusPublisher - 發布載具狀態到 ROS2 topics 1. VehicleStatusPublisher - 發布載具狀態到 ROS2 topics
從 vehicle_registry 讀取狀態數據,頻率控制,模組化設計 從 vehicle_registry 讀取狀態數據,頻率控制,模組化設計
2. MavlinkCommandService - 提供 MAVLink 指令 service 介面 2. MavlinkCommandService - 提供 MAVLink 指令 service 介面
以最基礎的 mavlink microservice 會實現目標 以最基礎的 mavlink microservice 會實現目標
並不會包含額外的功能 並不會包含額外的功能
3. RtcmRelay - 訂閱 RTCM topic 並轉發為 MAVLink GPS_RTCM_DATA 給所有載具
過期丟棄、去重、節流、分片
與一個節點管理器 與一個節點管理器
- fc_ros_manager - fc_ros_manager
@@ -15,6 +17,7 @@ MAVLink ROS2 Nodes
import os import os
import time import time
import math import math
import hashlib
import threading import threading
from typing import Dict, Optional from typing import Dict, Optional
@@ -22,6 +25,7 @@ from typing import Dict, Optional
import rclpy import rclpy
from rclpy.node import Node from rclpy.node import Node
from rclpy.executors import MultiThreadedExecutor from rclpy.executors import MultiThreadedExecutor
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy, DurabilityPolicy
# ROS2 Message imports # ROS2 Message imports
import std_msgs.msg import std_msgs.msg
@@ -32,7 +36,8 @@ import mavros_msgs.msg
# ROS2 Service imports # ROS2 Service imports
import fc_interfaces.srv as fcsrv import fc_interfaces.srv as fcsrv
from .ackEnum import serviceAckResult import fc_interfaces.msg as fcmsg
from fc_interfaces.msg import ServiceAckResult
# 自定義 imports # 自定義 imports
from . import mavlinkVehicleView as mvv from . import mavlinkVehicleView as mvv
@@ -40,7 +45,10 @@ from . import mavlinkObject as mo
from .utils import setup_logger from .utils import setup_logger
logger = setup_logger(os.path.basename(__file__)) logger = setup_logger(os.path.basename(__file__))
MODULE_VER = "1.50" MODULE_VER = "2.50"
FC_ROS_DOMAIN_ID = "0"
NODE_KEYS = ("status_publisher", "command_service", "rtcm_relay")
# ============================================================================ # ============================================================================
# VehicleStatusPublisher Node # VehicleStatusPublisher Node
@@ -60,14 +68,16 @@ class PublishRateController:
# 注意 這邊是定義區 不要把參數寫在這裡 所以預設全部關閉 # 注意 這邊是定義區 不要把參數寫在這裡 所以預設全部關閉
# 以這個專案 請看 mainOrchestrator.py 的 Orchestrator 初始化階段 # 以這個專案 請看 mainOrchestrator.py 的 Orchestrator 初始化階段
self.topic_intervals = { self.topic_intervals = {
'position': 0.0, # GNSS位置 'summary': 0.0, # 載具摘要 (sysid 飛行模式 解鎖上鎖 gps狀態)
'position_ned': 0.0, # LOCAL_POSITION_NED (位置+速度) 'position_gnss': 0.0, # GNSS位置 (海拔高度)
'attitude': 0.0, # 姿態 (pitch yaw row 與其加速狀態) 'position_ned': 0.0, # LOCAL_POSITION_NED (位置+速度+相對高度)
'velocity': 0.0, # 速度 (已經包含在 vfr_hud 未來移除) 'attitude': 0.0, # 姿態 (pitch yaw row 與其加速狀態)
'battery': 0.0, # 電池 'battery': 0.0, # 電池
'vfr_hud': 0.0, # VFR HUD (地速 空速 絕對高度 爬升率 航向 油門) 'vfr_hud': 0.0, # VFR HUD (地速 空速 絕對高度 爬升率 航向 油門)
'mode': 0.0, # 飛行模式 (已經在 summary 裡 未來移除) 'sys_diags': 0.0, # SYS_STATUS 系統診斷
'summary': 0.0, # 載具摘要 (sysid 飛行模式 解鎖上鎖 gps狀態) 'status_text': 0.0, # STATUSTEXT 飛控文字(佇列驅動,>0 僅作啟用旗標)
'mode': 0.0, # 飛行模式 (已經在 summary 裡 未來移除)
'velocity': 0.0, # 速度 (已經包含在 vfr_hud 未來移除)
# 在這裡新增更多 topics... # 在這裡新增更多 topics...
} }
# 記錄每個 topic 的最後發布時間 {(sysid, topic): timestamp} # 記錄每個 topic 的最後發布時間 {(sysid, topic): timestamp}
@@ -104,6 +114,10 @@ class PublishRateController:
return False return False
def is_topic_enabled(self, topic: str) -> bool:
"""檢查 topic 是否啟用(interval > 0)"""
return self.topic_intervals.get(topic, 0) > 0
def reset(self): def reset(self):
"""重置所有計時器""" """重置所有計時器"""
self.last_publish_time.clear() self.last_publish_time.clear()
@@ -114,7 +128,7 @@ class VehicleStatusPublisher(Node):
職責: 職責:
- 定期從 vehicle_registry 讀取載具狀態 - 定期從 vehicle_registry 讀取載具狀態
- 頻率控制(位置/姿態 2Hz,電池/摘要 1Hz) - 頻率控制 (位置/姿態 2Hz, 電池/摘要 1Hz)
- 發布標準 ROS2 消息類型 - 發布標準 ROS2 消息類型
- 檢測訂閱者,按需發布 - 檢測訂閱者,按需發布
""" """
@@ -130,8 +144,8 @@ class VehicleStatusPublisher(Node):
self.fc_publishers: Dict[tuple, any] = {} self.fc_publishers: Dict[tuple, any] = {}
# 定時器:以較高頻率檢查 vehicle_registry 並發布 # 定時器:以較高頻率檢查 vehicle_registry 並發布
# 10Hz 檢查頻率,但通過 rate_controller 控制實際發布頻率 # 20Hz 檢查頻率,但通過 rate_controller 控制實際發布頻率
self.timer_period = 0.1 # 100ms self.timer_period = 0.05 # 50ms
self.timer = self.create_timer(self.timer_period, self.timer_callback) self.timer = self.create_timer(self.timer_period, self.timer_callback)
# 狀態標誌 # 狀態標誌
@@ -170,14 +184,16 @@ class VehicleStatusPublisher(Node):
# 例如:self._publish_ekf_status(sysid, status) # 例如:self._publish_ekf_status(sysid, status)
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# 發布各種狀態(通過頻率控制器判斷是否發布) # 發布各種狀態(通過頻率控制器判斷是否發布)
self._publish_position(sysid, status) self._publish_position_gnss(sysid, status)
self._publish_position_ned(sysid, status) self._publish_position_ned(sysid, status)
self._publish_attitude(sysid, status) self._publish_attitude(sysid, status)
self._publish_velocity(sysid, status)
self._publish_battery(sysid, status) self._publish_battery(sysid, status)
self._publish_vfr_hud(sysid, status) self._publish_vfr_hud(sysid, status)
self._publish_mode(sysid, status)
self._publish_summary(vehicle) self._publish_summary(vehicle)
self._publish_system_diagnostics(sysid, status)
self._publish_status_text(sysid, status)
self._publish_velocity(sysid, status)
self._publish_mode(sysid, status)
# 在這裡新增更多 publish 方法調用... # 在這裡新增更多 publish 方法調用...
def _get_or_create_publisher(self, sysid: int, topic: str, msg_type, qos: int = 1): def _get_or_create_publisher(self, sysid: int, topic: str, msg_type, qos: int = 1):
@@ -201,9 +217,9 @@ class VehicleStatusPublisher(Node):
logger.info(f"Created publisher: {topic_name}") logger.info(f"Created publisher: {topic_name}")
return self.fc_publishers[key] return self.fc_publishers[key]
def _publish_position(self, sysid: int, status: mvv.ComponentStatus): def _publish_position_gnss(self, sysid: int, status: mvv.ComponentStatus):
"""發布 GPS 位置""" """發布 GPS 位置"""
if not self.rate_controller.should_publish(sysid, 'position'): if not self.rate_controller.should_publish(sysid, 'position_gnss'):
return return
pos = status.position pos = status.position
@@ -211,27 +227,32 @@ class VehicleStatusPublisher(Node):
return return
publisher = self._get_or_create_publisher( publisher = self._get_or_create_publisher(
sysid, 'position', sensor_msgs.msg.NavSatFix sysid, 'position_gnss', fcmsg.GnssRaw
) )
# 檢查是否有訂閱者 # 檢查是否有訂閱者
if publisher.get_subscription_count() == 0: if publisher.get_subscription_count() == 0:
return return
msg = sensor_msgs.msg.NavSatFix() msg = fcmsg.GnssRaw()
msg.latitude = pos.latitude msg.stamp = self.get_clock().now().to_msg()
msg.longitude = pos.longitude msg.latitude = float(pos.latitude)
msg.altitude = pos.altitude if pos.altitude is not None else 0.0 msg.longitude = float(pos.longitude)
msg.altitude = float(pos.altitude) if pos.altitude is not None else 0.0
# GPS 狀態資訊 # GPS 狀態資訊
gps = status.gps gps = status.gps
if gps.fix_type is not None: if gps.fix_type is not None:
msg.status.status = gps.fix_type - 1 # MAVLink fix_type 轉 NavSatStatus msg.fix_type = int(gps.fix_type)
if gps.satellites_visible is not None:
msg.satellites_visible = int(gps.satellites_visible)
msg.eph = float(gps.eph) / 100.0 if gps.eph is not None else float('nan')
msg.epv = float(gps.epv) / 100.0 if gps.epv is not None else float('nan')
publisher.publish(msg) publisher.publish(msg)
def _publish_position_ned(self, sysid: int, status: mvv.ComponentStatus): def _publish_position_ned(self, sysid: int, status: mvv.ComponentStatus):
"""發布 LOCAL_POSITION_NED(NED 座標,含位置與速度)""" """發布 LOCAL_POSITION_NED (NED 座標,含位置與速度)"""
if not self.rate_controller.should_publish(sysid, 'position_ned'): if not self.rate_controller.should_publish(sysid, 'position_ned'):
return return
@@ -267,34 +288,28 @@ class VehicleStatusPublisher(Node):
publisher.publish(msg) publisher.publish(msg)
def _publish_attitude(self, sysid: int, status: mvv.ComponentStatus): def _publish_attitude(self, sysid: int, status: mvv.ComponentStatus):
"""發布姿態(IMU)""" """發布原始姿態(roll/pitch/yaw 與角速度)"""
if not self.rate_controller.should_publish(sysid, 'attitude'): if not self.rate_controller.should_publish(sysid, 'attitude'):
return return
att = status.attitude att = status.attitude
if att.roll is None: if att.roll is None or att.pitch is None or att.yaw is None:
return return
publisher = self._get_or_create_publisher(sysid, 'attitude', sensor_msgs.msg.Imu) publisher = self._get_or_create_publisher(
sysid, 'attitude', fcmsg.AttitudeRaw
)
if publisher.get_subscription_count() == 0: if publisher.get_subscription_count() == 0:
return return
msg = sensor_msgs.msg.Imu() msg = fcmsg.AttitudeRaw()
msg.stamp = self.get_clock().now().to_msg()
# 歐拉角轉四元數 msg.roll = float(att.roll)
qx, qy, qz, qw = self._euler_to_quaternion( msg.pitch = float(att.pitch)
att.roll, att.pitch, att.yaw msg.yaw = float(att.yaw)
) msg.rollspeed = float(att.rollspeed) if att.rollspeed is not None else 0.0
msg.orientation.x = qx msg.pitchspeed = float(att.pitchspeed) if att.pitchspeed is not None else 0.0
msg.orientation.y = qy msg.yawspeed = float(att.yawspeed) if att.yawspeed is not None else 0.0
msg.orientation.z = qz
msg.orientation.w = qw
# 角速度
if att.rollspeed is not None:
msg.angular_velocity.x = att.rollspeed
msg.angular_velocity.y = att.pitchspeed
msg.angular_velocity.z = att.yawspeed
publisher.publish(msg) publisher.publish(msg)
@@ -432,11 +447,7 @@ class VehicleStatusPublisher(Node):
'armed': status.armed if status.armed is not None else False, 'armed': status.armed if status.armed is not None else False,
# 'mode_custom': status.mode.custom_mode if status.mode.custom_mode else 0, # 'mode_custom': status.mode.custom_mode if status.mode.custom_mode else 0,
'mode_name': status.mode.mode_name if status.mode.mode_name else "UNKNOWN", 'mode_name': status.mode.mode_name if status.mode.mode_name else "UNKNOWN",
# 'latitude': status.position.latitude if status.position.latitude else 0.0, 'mav_status': status.system_status if status.system_status is not None else 0,
# 'longitude': status.position.longitude if status.position.longitude else 0.0,
# 'altitude': status.position.altitude if status.position.altitude else 0.0,
# 'battery_percent': status.battery.remaining if status.battery.remaining else 0,
'gps_fix': status.gps.fix_type if status.gps.fix_type else 0,
'connection_type': vehicle.connected_via.value, 'connection_type': vehicle.connected_via.value,
'last_update': component.packet_stats.last_msg_time if component.packet_stats.last_msg_time else 0.0, 'last_update': component.packet_stats.last_msg_time if component.packet_stats.last_msg_time else 0.0,
} }
@@ -445,6 +456,62 @@ class VehicleStatusPublisher(Node):
msg.data = json.dumps(summary) msg.data = json.dumps(summary)
publisher.publish(msg) publisher.publish(msg)
def _publish_system_diagnostics(self, sysid: int, status: mvv.ComponentStatus):
"""發布 SYS_STATUS 系統診斷資訊"""
if not self.rate_controller.should_publish(sysid, 'sys_diags'):
return
diag = status.sys_diag
if diag.sensors_install_mask is None:
return
publisher = self._get_or_create_publisher(
sysid, 'sys_diags', fcmsg.SystemDiagnosticsRaw
)
if publisher.get_subscription_count() == 0:
return
msg = fcmsg.SystemDiagnosticsRaw()
msg.stamp = self.get_clock().now().to_msg()
msg.sensors_install_mask = int(diag.sensors_install_mask)
msg.sensors_enabled_mask = int(diag.sensors_enabled_mask or 0)
msg.sensors_health_mask = int(diag.sensors_health_mask or 0)
msg.mcu_load = int(diag.mcuLoad or 0)
msg.bus_error_rate = int(diag.busErrorRate or 0)
msg.bus_error_count = int(diag.busErrorCount or 0)
msg.errors_count1 = int(diag.errors_count1 or 0)
msg.errors_count2 = int(diag.errors_count2 or 0)
msg.errors_count3 = int(diag.errors_count3 or 0)
msg.errors_count4 = int(diag.errors_count4 or 0)
publisher.publish(msg)
def _publish_status_text(self, sysid: int, status: mvv.ComponentStatus):
"""發布 STATUSTEXT 飛控文字 (佇列 drain, 無訂閱者直接丟棄) """
# 是否啟用
if not self.rate_controller.is_topic_enabled('status_text'):
return
# 是否有資料
queue = status.status_text_queue
if not queue:
return
publisher = self._get_or_create_publisher(sysid, 'status_text', std_msgs.msg.String)
# 是否有監聽者
if publisher.get_subscription_count() == 0:
queue.clear()
return
while queue:
entry = queue.popleft()
msg = std_msgs.msg.String()
ts = entry.timestamp if entry.timestamp is not None else 0.0
sev = entry.severity if entry.severity is not None else -1
msg.data = f'[{ts:.3f}] [{sev}] {entry.text}'
publisher.publish(msg)
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# 【新增 Topic 位置 3/4】 # 【新增 Topic 位置 3/4】
# 若要新增 topic,請在此處實作對應的發布方法 # 若要新增 topic,請在此處實作對應的發布方法
@@ -463,29 +530,6 @@ class VehicleStatusPublisher(Node):
# # ... 實作發布邏輯 # # ... 實作發布邏輯
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
@staticmethod
def _euler_to_quaternion(roll, pitch, yaw):
"""
歐拉角轉四元數
Args:
roll: 橫滾角 (弧度)
pitch: 俯仰角 (弧度)
yaw: 偏航角 (弧度)
Returns:
tuple: (qx, qy, qz, qw)
"""
qx = math.sin(roll/2) * math.cos(pitch/2) * math.cos(yaw/2) - \
math.cos(roll/2) * math.sin(pitch/2) * math.sin(yaw/2)
qy = math.cos(roll/2) * math.sin(pitch/2) * math.cos(yaw/2) + \
math.sin(roll/2) * math.cos(pitch/2) * math.sin(yaw/2)
qz = math.cos(roll/2) * math.cos(pitch/2) * math.sin(yaw/2) - \
math.sin(roll/2) * math.sin(pitch/2) * math.cos(yaw/2)
qw = math.cos(roll/2) * math.cos(pitch/2) * math.cos(yaw/2) + \
math.sin(roll/2) * math.sin(pitch/2) * math.sin(yaw/2)
return (qx, qy, qz, qw)
def stop(self): def stop(self):
"""停止發布""" """停止發布"""
self.running = False self.running = False
@@ -523,6 +567,14 @@ class MavlinkCommandService(Node):
講白話一點就是 講白話一點就是
每次接到一個 service 請求 要整個系統丟某種指令給載具時 每次接到一個 service 請求 要整個系統丟某種指令給載具時
會做兩件事 1."丟出mavlink封包" 2."創造一個臨時信箱 Pending" 會做兩件事 1."丟出mavlink封包" 2."創造一個臨時信箱 Pending"
然後透過每次 manager spin
會去呼叫 return_router() 方法
這個方法會監聽 return_packet_ring 跟臨時信箱的 Pending 做配對
配對到的解開 Pending
解開後 相對應的 handle_XXX 就會開始做事
""" """
@@ -531,7 +583,7 @@ class MavlinkCommandService(Node):
def __init__(self): def __init__(self):
super().__init__('mavlink_command_service') super().__init__('mavlink_command_service')
# 狀態標記 # 狀態標記
self.running = True self.running = True
# # mavlinkObject 的引用(將由外部設置) 用不到 # # mavlinkObject 的引用(將由外部設置) 用不到
@@ -577,7 +629,7 @@ class MavlinkCommandService(Node):
self.handle_set_position_target_global_int self.handle_set_position_target_global_int
) )
logger.info("MavlinkCommandService initialized") # logger.info("MavlinkCommandService initialized")
def _index(self, target_sysid, target_compid): def _index(self, target_sysid, target_compid):
@@ -604,20 +656,19 @@ class MavlinkCommandService(Node):
藉由 event.set() 解開 service 中的 block 藉由 event.set() 解開 service 中的 block
''' '''
return_tuple = mo.return_packet_ring.get() return_tuple = mo.return_packet_ring.get()
# 確認 return_packet_ring 有資料
if return_tuple == None: return if return_tuple == None: return
socketid, timestamp, msg = return_tuple socketid, timestamp, msg = return_tuple
sysid = msg.get_srcSystem() sysid = msg.get_srcSystem()
_pending = self._pending_by_sysid.get(sysid) _pending = self._pending_by_sysid.get(sysid)
# 確認是否有 service 在等待回應 若無直接 return 此封包也會被忽略 if _pending is None:
if _pending == None:
return return
if _pending.match_fn(msg): if _pending.match_fn(msg):
_pending.result_mav_msg = msg _pending.result_mav_msg = msg
_pending.event.set() _pending.event.set()
return
# ═══════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════
# Service Handler: 發送 TIMESYNC 可以作為模板 # Service Handler: 發送 TIMESYNC 可以作為模板
@@ -702,20 +753,20 @@ class MavlinkCommandService(Node):
# 設定失效回應 # 設定失效回應
response.success = False response.success = False
response.message = "Unknown error" response.message = "Unknown error"
response.ack_result = serviceAckResult.UNKNOWN response.ack_result = ServiceAckResult.FCSYS_UNKNOWN
timeout_sec = request.timeout_sec timeout_sec = request.timeout_sec
# 1) 確認是否已經有相同 sysid 的其他需求正在 pending # 1) 確認是否已經有相同 sysid 的其他需求正在 pending
if request.target_sysid in self._pending_by_sysid: if request.target_sysid in self._pending_by_sysid:
response.message = f"sysid {request.target_sysid} already has pending request" response.message = f"sysid {request.target_sysid} already has pending request"
response.ack_result = serviceAckResult.MAVLINK_BUSY response.ack_result = ServiceAckResult.FCSYS_BUSY
return response return response
# 2) 找到 socket 標地 # 2) 找到 socket 標地
socketObject = self._index(request.target_sysid, request.target_compid) socketObject = self._index(request.target_sysid, request.target_compid)
if socketObject is None: if socketObject is None:
response.message = "This system id not found." response.message = "This system id not found."
response.ack_result = serviceAckResult.MAVLINK_DEV_NOTFOUND response.ack_result = ServiceAckResult.FCSYS_DEVICE_NOT_FOUND
return response return response
# 3) 接收封包系統 的設定 # 3) 接收封包系統 的設定
@@ -746,12 +797,13 @@ class MavlinkCommandService(Node):
# 5) 等待回應封包 # 5) 等待回應封包
if not evt.wait(timeout_sec): if not evt.wait(timeout_sec):
response.message = "mavlink timeout - CommLONG" response.message = "mavlink timeout - CommLONG"
response.ack_result = serviceAckResult.MAVLINK_TIMEOUT response.ack_result = ServiceAckResult.FCSYS_MAVLINK_TIMEOUT
fail_skip = True fail_skip = True
# 6) 處理回應封包 # 6) 處理回應封包
if not fail_skip: if not fail_skip:
ack_msg = _pending.result_mav_msg ack_msg = _pending.result_mav_msg
# response.success = ServiceAckResult.FCSYS_MAVLINK_BREAK # dev 故意觸發錯誤用的
response.success = (ack_msg.result == 0) # mavutil.mavlink.MAV_RESULT_ACCEPTED response.success = (ack_msg.result == 0) # mavutil.mavlink.MAV_RESULT_ACCEPTED
response.message = "" # 沒有消息就是好消息 response.message = "" # 沒有消息就是好消息
response.ack_result = ack_msg.result response.ack_result = ack_msg.result
@@ -775,20 +827,20 @@ class MavlinkCommandService(Node):
''' '''
# 設定失效回應 # 設定失效回應
response.message = "Unknown error" response.message = "Unknown error"
response.ack_result = serviceAckResult.UNKNOWN response.ack_result = ServiceAckResult.FCSYS_UNKNOWN
timeout_sec = request.timeout_sec timeout_sec = request.timeout_sec
# 1) 確認是否已經有相同 sysid 的其他需求正在 pending # 1) 確認是否已經有相同 sysid 的其他需求正在 pending
if request.target_sysid in self._pending_by_sysid: if request.target_sysid in self._pending_by_sysid:
response.message = f"sysid {request.target_sysid} already has pending request" response.message = f"sysid {request.target_sysid} already has pending request"
response.ack_result = serviceAckResult.MAVLINK_BUSY response.ack_result = ServiceAckResult.FCSYS_BUSY
return response return response
# 2) 找到 socket 標地 # 2) 找到 socket 標地
socketObject = self._index(request.target_sysid, request.target_compid) socketObject = self._index(request.target_sysid, request.target_compid)
if socketObject is None: if socketObject is None:
response.message = "This system id not found." response.message = "This system id not found."
response.ack_result = serviceAckResult.MAVLINK_DEV_NOTFOUND response.ack_result = ServiceAckResult.FCSYS_DEVICE_NOT_FOUND
return response return response
# 3) 接收封包系統 的設定 # 3) 接收封包系統 的設定
@@ -823,7 +875,7 @@ class MavlinkCommandService(Node):
# 5) 等待回應封包 # 5) 等待回應封包
if not evt.wait(timeout_sec): if not evt.wait(timeout_sec):
response.message = "mavlink timeout - SET POSITION 86" response.message = "mavlink timeout - SET POSITION 86"
response.ack_result = MAVLINK_SEND_BUT_NO_EXP_STEAM response.ack_result = ServiceAckResult.FCSYS_NO_EXPECTED_FEEDBACK
fail_skip = True fail_skip = True
# 6) 處理回應封包 # 6) 處理回應封包
@@ -831,7 +883,7 @@ class MavlinkCommandService(Node):
ack_msg = _pending.result_mav_msg ack_msg = _pending.result_mav_msg
response.message = "" response.message = ""
response.ack_result = 0 response.ack_result = ServiceAckResult.MAV_RESULT_ACCEPTED
response.r_time_boot_ms = ack_msg.time_boot_ms response.r_time_boot_ms = ack_msg.time_boot_ms
response.r_type_mask = ack_msg.type_mask response.r_type_mask = ack_msg.type_mask
response.r_lat_int = ack_msg.lat_int response.r_lat_int = ack_msg.lat_int
@@ -948,11 +1000,183 @@ class MavlinkCommandService(Node):
return response return response
def stop(self): def stop(self):
"""停止服務""" """停止服務"""
self.running = False self.running = False
logger.info("MavlinkCommandService stopped") # logger.info("MavlinkCommandService stopped")
# ============================================================================
# RtcmRelay Node
# ============================================================================
class RtcmRelay(Node):
"""
RTCM 轉發節點 - 訂閱 RTCM topic 並轉發為 MAVLink GPS_RTCM_DATA 給所有載具
職責:
- 訂閱外部 RTCM 資料(mavros_msgs/msg/RTCM)
- 過期丟棄:封包 Header.stamp 超過 1 秒則丟棄
- 去重:與上一筆 payload blake2s hash 相同則丟棄
- 節流:最短轉發間隔保護(可參數化)
- 對所有在線載具直接編碼並送出 MAVLink GPS_RTCM_DATA(含分片)
MAVLink GPS_RTCM_DATA (msg_id=233) 每片 payload 上限 180 bytes
超過 180 bytes 需分片,最多 4 片 = 720 bytes
"""
FRAGMENT_MAX_LEN = 180
MAX_TOTAL_LEN = 4 * 180 # 720 bytes
STALE_THRESHOLD_SEC = 1.0
def __init__(self, min_forward_interval_ms: float = 0.0, clock_offset_ms: float = 0.0):
super().__init__('rtcm_relay')
self.running = True
self.min_forward_interval_sec = min_forward_interval_ms / 1000.0
self.clock_offset_sec = clock_offset_ms / 1000.0
# 去重:上一筆 payload 的短 hash
self._last_hash: Optional[bytes] = None
# 節流:上次成功轉發的時間
self._last_forward_time: float = 0.0
# MAVLink GPS_RTCM_DATA 分片序號 (0~31 循環,同一筆 RTCM 的所有分片共用)
self._seq: int = 0
# 觀測 counters
self.counters = {
'received': 0,
'forwarded': 0,
'dropped_stale': 0,
'dropped_duplicate': 0,
'dropped_throttle': 0,
'dropped_oversize': 0,
'no_route': 0,
'send_fail': 0,
}
# QoS: 低延遲、弱可靠 — 寧可掉舊包也不排隊
rtcm_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=1,
reliability=ReliabilityPolicy.BEST_EFFORT,
durability=DurabilityPolicy.VOLATILE,
)
self.subscription = self.create_subscription(
mavros_msgs.msg.RTCM,
'/fc_network/rtcm_input',
self._rtcm_callback,
rtcm_qos,
)
# logger.info("RtcmRelay initialized")
# ── callback ──────────────────────────────────────────────────────
def _rtcm_callback(self, msg):
"""處理順序:過期檢查 → 去重 → 節流 → 轉發"""
if not self.running:
return
self.counters['received'] += 1
data = bytes(msg.data)
now = time.time()
# 1) 過期丟棄
stamp_sec = msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9
age = (now + self.clock_offset_sec) - stamp_sec
if age > self.STALE_THRESHOLD_SEC:
self.counters['dropped_stale'] += 1
return
# 2) 去重:blake2s 短 hash 與上一筆比對
h = hashlib.blake2s(data, digest_size=8).digest()
if h == self._last_hash:
self.counters['dropped_duplicate'] += 1
return
self._last_hash = h
# 3) 節流:最短轉發間隔
if self.min_forward_interval_sec > 0:
if (now - self._last_forward_time) < self.min_forward_interval_sec:
self.counters['dropped_throttle'] += 1
return
# 4) 超長丟棄
if len(data) > self.MAX_TOTAL_LEN:
self.counters['dropped_oversize'] += 1
logger.warning(f"RTCM data too large ({len(data)} bytes), max {self.MAX_TOTAL_LEN}")
return
# 5) 取得本次的分片序號,然後遞增給下一筆
seq = self._seq
self._seq = (self._seq + 1) & 0x1F
# 6) 轉發給所有載具
forwarded_any = False
for sysid, vehicle in mvv.vehicle_registry.read_all():
socket_id = vehicle.custom_meta.get("socket_id")
if socket_id is None:
self.counters['no_route'] += 1
continue
mav_obj = mo.mavlink_object.mavlinkObjects.get(socket_id)
if mav_obj is None:
self.counters['no_route'] += 1
continue
try:
self._send_rtcm(mav_obj, data, seq)
forwarded_any = True
except Exception as e:
self.counters['send_fail'] += 1
logger.debug(f"RTCM send fail sysid={sysid}: {e}")
if forwarded_any:
self.counters['forwarded'] += 1
self._last_forward_time = now
# ── MAVLink 編碼與分片 ────────────────────────────────────────────
def _send_rtcm(self, mav_obj, data: bytes, seq: int):
"""
對單一載具送出 GPS_RTCM_DATA 必要時做分片
GPS_RTCM_DATA flags 欄位:
bit 0 : 1=fragmented
bit 1-2 : fragment_id (0~3)
bit 3-7 : sequence number (0~31)
"""
if len(data) <= self.FRAGMENT_MAX_LEN:
flags = (seq & 0x1F) << 3
padded = data + b'\x00' * (self.FRAGMENT_MAX_LEN - len(data))
mav_obj.MAVLink.gps_rtcm_data_send(flags, len(data), padded)
else:
fragments = [
data[i:i + self.FRAGMENT_MAX_LEN]
for i in range(0, len(data), self.FRAGMENT_MAX_LEN)
]
for frag_id, frag_data in enumerate(fragments):
flags = 1 | ((frag_id & 0x03) << 1) | ((seq & 0x1F) << 3)
padded = frag_data + b'\x00' * (self.FRAGMENT_MAX_LEN - len(frag_data))
mav_obj.MAVLink.gps_rtcm_data_send(flags, len(frag_data), padded)
# ── 外部介面 ──────────────────────────────────────────────────────
def set_clock_offset(self, offset_ms: float):
"""更新時鐘偏移量(毫秒),用於與 RTCM 來源的時間對齊"""
self.clock_offset_sec = offset_ms / 1000.0
def get_counters(self) -> dict:
return dict(self.counters)
def stop(self):
"""停止轉發"""
self.running = False
# logger.info("RtcmRelay stopped")
# ============================================================================ # ============================================================================
@@ -968,20 +1192,29 @@ class fc_ros_manager:
stop 是停下止 ROS2 nodes 的運行,但不銷毀節點實例,允許後續再次 start。 stop 是停下止 ROS2 nodes 的運行,但不銷毀節點實例,允許後續再次 start。
shutdown 是完全關閉 ROS2 並銷毀節點實例。 shutdown 是完全關閉 ROS2 並銷毀節點實例。
管理兩個獨立的 ROS2 Node: 管理三個獨立的 ROS2 Node :
- VehicleStatusPublisher - VehicleStatusPublisher
- MavlinkCommandService - MavlinkCommandService
- RtcmRelay
提供統一的啟動/停止介面給 mainOrchestrator 提供統一的啟動/停止介面給 mainOrchestrator
另外 這邊用到 MultiThreadedExecutor 會開出額外的 thread 的特性
使得就算 executor 在跑一些需要等待的方法
常態的 spin_once 也不會被 block (spin_thread 是另一個支線)
""" """
def __init__(self): def __init__(self):
self.initialized = False self.initialized = False
self.running = False self.running = False
self._restarting = False
self._pending_restart: Optional[str] = None
self._restart_lock = threading.Lock()
# 两个 node 實例 # node 實例
self.status_publisher: Optional[VehicleStatusPublisher] = None self.status_publisher: Optional[VehicleStatusPublisher] = None
self.command_service: Optional[MavlinkCommandService] = None self.command_service: Optional[MavlinkCommandService] = None
self.rtcm_relay: Optional[RtcmRelay] = None
# Executor & Thread # Executor & Thread
self.spin_thread: Optional[threading.Thread] = None self.spin_thread: Optional[threading.Thread] = None
@@ -994,18 +1227,20 @@ class fc_ros_manager:
return False return False
try: try:
# 初始化 ROS2 os.environ.setdefault("ROS_DOMAIN_ID", FC_ROS_DOMAIN_ID)
if not rclpy.ok(): if not rclpy.ok():
rclpy.init(args=None) rclpy.init(args=None)
# 創建節點 node # 創建節點 node
self.status_publisher = VehicleStatusPublisher() self.status_publisher = VehicleStatusPublisher()
self.command_service = MavlinkCommandService() self.command_service = MavlinkCommandService()
self.rtcm_relay = RtcmRelay()
# 創建執行者 MultiThreadedExecutor 並把 node 加入其中 # 創建執行者 MultiThreadedExecutor 並把 node 加入其中
self.executor = MultiThreadedExecutor() self.executor = MultiThreadedExecutor()
self.executor.add_node(self.status_publisher) self.executor.add_node(self.status_publisher)
self.executor.add_node(self.command_service) self.executor.add_node(self.command_service)
self.executor.add_node(self.rtcm_relay)
self.initialized = True self.initialized = True
# logger.info("fc_ros_manager initialized") # logger.info("fc_ros_manager initialized")
@@ -1027,6 +1262,10 @@ class fc_ros_manager:
try: try:
self.running = True self.running = True
self.status_publisher.running = True
self.command_service.running = True
self.rtcm_relay.running = True
self.spin_thread = threading.Thread( self.spin_thread = threading.Thread(
target=self._spin_executor, target=self._spin_executor,
@@ -1042,24 +1281,105 @@ class fc_ros_manager:
logger.error(f"Failed to start fc_ros_manager: {e}") logger.error(f"Failed to start fc_ros_manager: {e}")
self.running = False self.running = False
return False return False
# 給 orchestrator 呼叫的函數
def schedule_restart_node(self, node_key: str) -> bool:
"""由 orchestrator 排程單一 node 重啟(於 spin thread 下一輪執行)"""
if node_key not in NODE_KEYS:
logger.warning(f"schedule_restart_node: unknown key {node_key!r}")
return False
if not self.running:
logger.warning("schedule_restart_node: fc_ros_manager not running")
return False
if not self.spin_thread or not self.spin_thread.is_alive():
logger.warning("schedule_restart_node: spin thread not alive")
return False
with self._restart_lock:
self._pending_restart = node_key
logger.info(f"schedule_restart_node: queued {node_key}")
return True
# 循環執行的地方 # 循環執行的地方
def _spin_executor(self): def _spin_executor(self):
"""在 thread 中運行的 executor""" """在 thread 中運行的 executor"""
try: try:
# logger.info("ROS2 executor spinning...")
while self.running: while self.running:
self.executor.spin_once(timeout_sec=0.1) self.executor.spin_once(timeout_sec=0.1)
self.command_service.return_router()
pending = None
with self._restart_lock:
if self._pending_restart:
pending = self._pending_restart
self._pending_restart = None
if pending:
self._restart_node(pending)
elif self.command_service and not self._restarting:
self.command_service.return_router()
except Exception as e: except Exception as e:
logger.error(f"fc_ros_manager error in spinning executor: {e}") logger.error(f"fc_ros_manager error in spinning executor: {e}")
finally:
self.running = False
def _restart_node(self, node_key: str) -> bool:
"""僅由 spin thread 呼叫 : destroy 並重建單一 node"""
old_node = getattr(self, node_key, None)
if old_node is None:
logger.error(f"_restart_node: {node_key} is None")
return False
# 重置前保留舊有或處理設定
saved_topic_intervals = None
if node_key == "status_publisher":
saved_topic_intervals = dict(old_node.rate_controller.topic_intervals)
# 如果是 command_service 清除所有等待的 pending 並且清除 return message
if node_key == "command_service":
old_node._pending_by_sysid.clear()
for sockObj in mo.mavlink_object.mavlinkObjects.values():
sockObj.set_return_message_types([])
try:
self._restarting = True
# 停止運作 移出執行迴圈 銷毀資源
old_node.stop()
self.executor.remove_node(old_node)
old_node.destroy_node()
# 建立新的 node
if node_key == "status_publisher":
new_node = VehicleStatusPublisher()
if saved_topic_intervals is not None:
new_node.rate_controller.topic_intervals = saved_topic_intervals
elif node_key == "command_service":
new_node = MavlinkCommandService()
elif node_key == "rtcm_relay":
new_node = RtcmRelay()
else:
return False
# 加回去管理系統中
setattr(self, node_key, new_node)
self.executor.add_node(new_node)
logger.info(f"_restart_node: {node_key} restarted")
return True
except Exception as e:
logger.error(f"_restart_node failed for {node_key}: {e}")
return False
finally:
self._restarting = False
def stop(self): def stop(self):
"""停止 ROS2 nodes""" """停止 ROS2 nodes"""
if not self.running: # if not self.running:
logger.warning("fc_ros_manager not running") # logger.warning("fc_ros_manager not running")
# return False
if not self.spin_thread:
logger.warning("fc_ros_manager without thread to stop")
return False return False
try: try:
# 標記停止 # 標記停止
self.running = False self.running = False
@@ -1069,6 +1389,8 @@ class fc_ros_manager:
self.status_publisher.stop() self.status_publisher.stop()
if self.command_service: if self.command_service:
self.command_service.stop() self.command_service.stop()
if self.rtcm_relay:
self.rtcm_relay.stop()
# 等待 spin 執行緒結束 # 等待 spin 執行緒結束
if self.spin_thread and self.spin_thread.is_alive(): if self.spin_thread and self.spin_thread.is_alive():
@@ -1093,6 +1415,8 @@ class fc_ros_manager:
self.status_publisher.destroy_node() self.status_publisher.destroy_node()
if self.command_service: if self.command_service:
self.command_service.destroy_node() self.command_service.destroy_node()
if self.rtcm_relay:
self.rtcm_relay.destroy_node()
# 關閉 ROS2 # 關閉 ROS2
if rclpy.ok(): if rclpy.ok():
@@ -1104,14 +1428,21 @@ class fc_ros_manager:
except Exception as e: except Exception as e:
logger.error(f"Error during shutdown: {e}") logger.error(f"Error during shutdown: {e}")
def get_status(self) -> dict: def get_status(self):
return { return {
'initialized': self.initialized, 'initialized': self.initialized,
'running': self.running, 'running': self.running,
'status_publisher_active': self.status_publisher is not None and self.status_publisher.running, 'status_publisher_active': self.status_publisher is not None and self.status_publisher.running,
'command_service_active': self.command_service is not None, 'command_service_active': self.command_service is not None,
'rtcm_relay_active': self.rtcm_relay is not None and self.rtcm_relay.running,
} }
# ============================================================================ # ============================================================================
# 全域實例 # 全域實例
@@ -1137,10 +1468,25 @@ ros2_manager = fc_ros_manager()
2026.04.07 2026.04.07
1. 完成 ros2 的 MavPositionTargetGlobalInt 區域 1. 完成 ros2 的 MavPositionTargetGlobalInt 區域
2. 優化 response.ack_result 回傳值的資訊 2. 優化 response.ack_result 回傳值的資訊
3. 新增 _publish_position_ned() 發布剛體座標的訊息
4. 重製 _publish_attitude() 使其更簡單的去發布姿態訊息
2026.05.11
1. 新增 RtcmRelay node - 訂閱 mavros_msgs/RTCM topic 轉發為 MAVLink GPS_RTCM_DATA
2. 實作過期丟棄(1s)、blake2s hash 去重、最短轉發間隔節流、分片(180 bytes/片)
3. 接入 fc_ros_manager 的 initialize/start/stop/shutdown 生命週期
2026.05.22
1. initialize() 設定 ROS_DOMAIN_ID ( FC_ROS_DOMAIN_ID )
2. schedule_restart_node / _restart_node : 手動重啟單一 node (spin thread 內執行)
3. orchestrator cmd: ("RESTART_ROS_NODE", node_key), node_key 見 NODE_KEYS
2026.06.10
1. 增加了 _publish_system_diagnostics 與 _publish_status_text 功能
TODO TODO
1. service 部分會需要跟 mavlinkobject 大量互動 也許需要考慮對方的生命週期 1. service 部分會需要跟 mavlinkobject 大量互動 也許需要考慮對方的生命週期
2. 還沒測試 如果有失效狀態讓 fc_ros_manager 中斷了 如何恢復的問題
''' '''
@@ -5,6 +5,7 @@ VehicleView - Pure State Container
""" """
import os import os
from collections import deque
from typing import Dict, Optional, Any, Tuple from typing import Dict, Optional, Any, Tuple
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
@@ -12,7 +13,7 @@ from enum import Enum
from .utils import setup_logger from .utils import setup_logger
logger = setup_logger(os.path.basename(__file__)) logger = setup_logger(os.path.basename(__file__))
MODULE_VER = "1.00" MODULE_VER = "1.10"
# ====================== Enums ===================== # ====================== Enums =====================
@@ -117,6 +118,30 @@ class VFR:
timestamp: Optional[float] = None # 時間戳記 timestamp: Optional[float] = None # 時間戳記
@dataclass
class StatusTextEntry:
"""飛控狀態文字(來源:MAVLink STATUSTEXT)"""
text: str
severity: Optional[int] = None
timestamp: Optional[float] = None
@dataclass
class SystemDiagnostics:
"""系統診斷資訊(來源:MAVLink SYS_STATUS,不含電池欄位)"""
sensors_install_mask: Optional[int] = None
sensors_enabled_mask: Optional[int] = None
sensors_health_mask: Optional[int] = None
mcuLoad: Optional[int] = None # 單位 1%
busErrorRate: Optional[int] = None # 單位 0.1%
busErrorCount: Optional[int] = None
errors_count1: Optional[int] = None
errors_count2: Optional[int] = None
errors_count3: Optional[int] = None
errors_count4: Optional[int] = None
timestamp: Optional[float] = None
@dataclass @dataclass
class ComponentStatus: class ComponentStatus:
"""組件狀態容器""" """組件狀態容器"""
@@ -127,6 +152,8 @@ class ComponentStatus:
ekf: EKF = field(default_factory=EKF) ekf: EKF = field(default_factory=EKF)
gps: GPS = field(default_factory=GPS) gps: GPS = field(default_factory=GPS)
vfr: VFR = field(default_factory=VFR) vfr: VFR = field(default_factory=VFR)
sys_diag: SystemDiagnostics = field(default_factory=SystemDiagnostics)
status_text_queue: deque = field(default_factory=lambda: deque(maxlen=64))
# 系統狀態 # 系統狀態
system_status: Optional[int] = None # MAV_STATE system_status: Optional[int] = None # MAV_STATE
@@ -38,6 +38,7 @@ class SerialMode(Enum):
"""連接類型""" """連接類型"""
STRAIGHT = auto() # 原始數據直通 STRAIGHT = auto() # 原始數據直通
XBEEAPI2AT = auto() # XBee API 模式 XBEEAPI2AT = auto() # XBee API 模式
XBEEAPI_POLL = auto()
NOT_USE = auto() # 不使用 NOT_USE = auto() # 不使用
@@ -176,10 +177,10 @@ class XBeeFrameProcessor(FrameProcessor):
"""根據 frame type 分派;若是 RX payload 回傳 bytes,其餘回傳 None""" """根據 frame type 分派;若是 RX payload 回傳 bytes,其餘回傳 None"""
frame_type = frame[3] frame_type = frame[3]
if frame_type == self.FRAME_TYPE_RX_PACKET: if frame_type == self.FRAME_TYPE_RX_PACKET: # mavlink
return self._decapsulate(frame) return self._decapsulate(frame)
if frame_type == self.FRAME_TYPE_AT_RESPONSE: if frame_type == self.FRAME_TYPE_AT_RESPONSE: # AT command
if self.at_handler is not None: if self.at_handler is not None:
self.at_handler.handle_frame(frame) self.at_handler.handle_frame(frame)
return None return None
@@ -335,9 +336,10 @@ class ATCommandHandler:
def _handle_rssi(self, data: bytes): def _handle_rssi(self, data: bytes):
"""處理 DB (RSSI) 回應:單 byte 無號值,單位 dBm""" """處理 DB (RSSI) 回應:單 byte 無號值,單位 dBm"""
pass pass
# if data: if data:
# print(f"[{self.serial_port}] RSSI = -{data[0]} dBm") # dev print(f"[{self.serial_port}] RSSI = -{data[0]} dBm") # dev
# logger.debug(f"[{self.serial_port}] RSSI = -{data[0]} dBm") # dev # logger.debug(f"[{self.serial_port}] RSSI = -{data[0]} dBm") # dev
pass
def _handle_serial_high(self, data: bytes): def _handle_serial_high(self, data: bytes):
"""處理 SH (Serial Number High)""" """處理 SH (Serial Number High)"""
@@ -370,6 +372,10 @@ class SerialHandler(asyncio.Protocol):
at_handler = ATCommandHandler(self.serial_port_str) at_handler = ATCommandHandler(self.serial_port_str)
return XBeeFrameProcessor(at_handler=at_handler) return XBeeFrameProcessor(at_handler=at_handler)
# if self.serial_mode == SerialMode.XBEEAPI_POLL:
# at_handler = ATCommandHandler_new(self.serial_port_str)
# return XBeeFrameProcessor(at_handler=at_handler)
logger.warning(f"Unknown serial mode: {self.serial_mode}, using Raw") logger.warning(f"Unknown serial mode: {self.serial_mode}, using Raw")
return RawFrameProcessor() return RawFrameProcessor()
+7
View File
@@ -7,6 +7,13 @@
<maintainer email="chiyu1468@hotmail.com">picars</maintainer> <maintainer email="chiyu1468@hotmail.com">picars</maintainer>
<license>TODO: License declaration</license> <license>TODO: License declaration</license>
<exec_depend>fc_interfaces</exec_depend>
<exec_depend>std_msgs</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>geometry_msgs</exec_depend>
<exec_depend>nav_msgs</exec_depend>
<exec_depend>mavros_msgs</exec_depend>
<test_depend>ament_copyright</test_depend> <test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend> <test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend> <test_depend>ament_pep257</test_depend>
@@ -17,6 +17,7 @@ import std_msgs.msg
import sensor_msgs.msg import sensor_msgs.msg
import geometry_msgs.msg import geometry_msgs.msg
import mavros_msgs.msg import mavros_msgs.msg
import fc_interfaces.msg
# 專案 imports # 專案 imports
from ..fc_network_adapter.mavlinkROS2Nodes import ( from ..fc_network_adapter.mavlinkROS2Nodes import (
@@ -39,7 +40,7 @@ class TestSubscriber(Node):
self.sysid = sysid self.sysid = sysid
self.received_messages = { self.received_messages = {
'position': [], 'position_gnss': [],
'attitude': [], 'attitude': [],
'velocity': [], 'velocity': [],
'battery': [], 'battery': [],
@@ -60,9 +61,9 @@ class TestSubscriber(Node):
# Position # Position
self.create_subscription( self.create_subscription(
sensor_msgs.msg.NavSatFix, fc_interfaces.msg.GnssRaw,
f'{base_topic}/position', f'{base_topic}/position_gnss',
lambda msg: self._on_message('position', msg), lambda msg: self._on_message('position_gnss', msg),
10 10
) )
@@ -121,7 +122,7 @@ class TestSubscriber(Node):
def _format_msg(self, topic_name: str, msg) -> str: def _format_msg(self, topic_name: str, msg) -> str:
"""格式化消息以便顯示""" """格式化消息以便顯示"""
if topic_name == 'position': if topic_name == 'position_gnss':
return f"lat={msg.latitude:.6f}, lon={msg.longitude:.6f}, alt={msg.altitude:.2f}m" return f"lat={msg.latitude:.6f}, lon={msg.longitude:.6f}, alt={msg.altitude:.2f}m"
elif topic_name == 'attitude': elif topic_name == 'attitude':
return f"quat=({msg.orientation.x:.3f}, {msg.orientation.y:.3f}, {msg.orientation.z:.3f}, {msg.orientation.w:.3f})" return f"quat=({msg.orientation.x:.3f}, {msg.orientation.y:.3f}, {msg.orientation.z:.3f}, {msg.orientation.w:.3f})"
@@ -264,7 +265,7 @@ def test_basic_publishing():
# 檢查收到的消息 # 檢查收到的消息
print("\n--- 消息統計 ---") print("\n--- 消息統計 ---")
total_messages = 0 total_messages = 0
for topic in ['position', 'attitude', 'velocity', 'battery', 'vfr_hud', 'mode', 'summary']: for topic in ['position_gnss', 'attitude', 'velocity', 'battery', 'vfr_hud', 'mode', 'summary']:
count = test_sub.get_message_count(topic) count = test_sub.get_message_count(topic)
total_messages += count total_messages += count
print(f" {topic:15s}: {count:3d} 條消息") print(f" {topic:15s}: {count:3d} 條消息")
@@ -304,7 +305,7 @@ def test_frequency_control():
# 修改頻率設定 # 修改頻率設定
publisher_node = ros2_manager.status_publisher publisher_node = ros2_manager.status_publisher
publisher_node.rate_controller.topic_intervals = { publisher_node.rate_controller.topic_intervals = {
'position': 1.5, 'position_gnss': 1.5,
'attitude': 1.0, 'attitude': 1.0,
'velocity': 1.0, 'velocity': 1.0,
'battery': 1.0, 'battery': 1.0,
@@ -329,7 +330,7 @@ def test_frequency_control():
print("預期:position 約 2 條 (0.67Hz),attitude/battery/velocity 約 3 條 (1Hz),vfr_hud 約 6 條 (2Hz) mode/summary 不發布") print("預期:position 約 2 條 (0.67Hz),attitude/battery/velocity 約 3 條 (1Hz),vfr_hud 約 6 條 (2Hz) mode/summary 不發布")
print("2Hz Topics (預期 ~6 條):") print("2Hz Topics (預期 ~6 條):")
for topic in ['position', 'attitude', 'velocity', 'vfr_hud']: for topic in ['position_gnss', 'attitude', 'velocity', 'vfr_hud']:
count = test_sub.get_message_count(topic) count = test_sub.get_message_count(topic)
print(f" {topic:15s}: {count:3d} 條") print(f" {topic:15s}: {count:3d} 條")
for topic in ['battery', 'mode', 'summary']: for topic in ['battery', 'mode', 'summary']:
+46
View File
@@ -14,6 +14,7 @@ from fc_interfaces.srv import MavCommandLong
COMMAND_DO_SET_MODE = 176 COMMAND_DO_SET_MODE = 176
COMMAND_NAV_LAND = 21 COMMAND_NAV_LAND = 21
COMMAND_NAV_TAKEOFF = 22 COMMAND_NAV_TAKEOFF = 22
COMMAND_PREFLIGHT_REBOOT_SHUTDOWN = 246
COMMAND_COMPONENT_ARM_DISARM = 400 COMMAND_COMPONENT_ARM_DISARM = 400
DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long" DEFAULT_SERVICE_NAME = "/fc_network/vehicle/send_command_long"
@@ -191,6 +192,28 @@ class CommandLongClient(Node):
timeout_sec=timeout_sec, timeout_sec=timeout_sec,
) )
def reboot_autopilot(
self,
*,
target_sysid: int,
target_compid: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
return self._send_command_long(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_PREFLIGHT_REBOOT_SHUTDOWN,
confirmation=0,
param1=1.0,
param2=0.0,
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
# ============================================================================ # ============================================================================
# 【新增】非阻塞 async 包裝方法(用於 GUI 的非阻塞調用) # 【新增】非阻塞 async 包裝方法(用於 GUI 的非阻塞調用)
# 這些方法在 ThreadPoolExecutor 中運行同步版本,以避免阻塞事件循環 # 這些方法在 ThreadPoolExecutor 中運行同步版本,以避免阻塞事件循環
@@ -353,6 +376,29 @@ class CommandLongClient(Node):
timeout_sec=timeout_sec, timeout_sec=timeout_sec,
) )
async def reboot_autopilot_async(
self,
*,
target_sysid: int,
target_compid: int = 0,
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> CommandLongResult:
"""非阻塞 async 版本的 autopilot reboot."""
return await self._send_command_long_async(
target_sysid=target_sysid,
target_compid=target_compid,
command=COMMAND_PREFLIGHT_REBOOT_SHUTDOWN,
confirmation=0,
param1=1.0,
param2=0.0,
param3=0.0,
param4=0.0,
param5=0.0,
param6=0.0,
param7=0.0,
timeout_sec=timeout_sec,
)
async def land_async( async def land_async(
self, self,
*, *,
@@ -0,0 +1,256 @@
import socket
import base64
import threading
import time
from typing import Optional, Tuple
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, HistoryPolicy, ReliabilityPolicy, DurabilityPolicy
from mavros_msgs.msg import RTCM
# TODO: ROS_DOMAIN_ID 要補一下
class GGA_stream():
@classmethod
def nmea_checksum(cls, body: str) -> str:
"""body 不含 '$' 與 '*checksum',例如 'GPGGA,123519,...'"""
value = 0
for ch in body:
value ^= ord(ch)
return f"{value:02X}"
@classmethod
def decimal_to_nmea_dm(cls, deg: float, *, is_latitude: bool) -> Tuple[str, str]:
"""十進位度 → NMEA 的 (d)dmm.mmmm 與半球字元。"""
if is_latitude:
hemi = "N" if deg >= 0 else "S"
deg_width = 2
else:
hemi = "E" if deg >= 0 else "W"
deg_width = 3
deg = abs(deg)
d = int(deg)
m = (deg - d) * 60.0
return f"{d:0{deg_width}d}{m:07.4f}", hemi
@classmethod
def build_gga_sentence(cls, lat_deg: float, lon_deg: float, alt_m: float = 100.0) -> bytes:
"""
組一筆 $GPGGA 句子(含 checksum) 回傳 bytes 可直接 sock.sendall。
lat_deg / lon_deg : 十進位經緯度(北、東為正)。
測試時請改成 mount 服務範圍內的近似位置;正式使用應來自 GNSS 真實定位。
"""
utc = time.gmtime()
t_str = f"{utc.tm_hour:02d}{utc.tm_min:02d}{utc.tm_sec:02d}.00"
lat_dm, ns = cls.decimal_to_nmea_dm(lat_deg, is_latitude=True)
lon_dm, ew = cls.decimal_to_nmea_dm(lon_deg, is_latitude=False)
# quality=1(GPS fix)、8 顆星、HDOP=1.0 僅供測試示意
body = (
f"GPGGA,{t_str},{lat_dm},{ns},{lon_dm},{ew},"
f"1,08,1.0,{alt_m:.1f},M,0.0,M,,"
)
sentence = f"${body}*{cls.nmea_checksum(body)}\r\n"
return sentence.encode("ascii")
@classmethod
def send_gga(cls, sock: socket.socket, lat_deg: float, lon_deg: float, alt_m: float = 100.0) -> str:
"""送出 GGA,回傳可讀句子供列印。"""
payload = cls.build_gga_sentence(lat_deg, lon_deg, alt_m)
sock.sendall(payload)
return payload.decode("ascii").strip()
class NtripClientNode(Node):
"""連線 NTRIP caster, 接收 RTCM3 資料流並發布至 ROS2 topic """
RECONNECT_BASE_SEC = 10.0
RECONNECT_MAX_SEC = 60.0
def __init__(self):
super().__init__('ntrip_client')
self.declare_parameter('host', 'rtk2go.com')
self.declare_parameter('port', 2101)
self.declare_parameter('mountpoint', '')
self.declare_parameter('username', 'template@mail.com')
self.declare_parameter('password', '')
self.declare_parameter('gga_on', False)
self.declare_parameter('gga_lat', None)
self.declare_parameter('gga_lon', None)
self.declare_parameter('gga_alt_m', 100)
self.declare_parameter('gga_interval_sec', 60)
rtcm_qos = QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=1,
reliability=ReliabilityPolicy.BEST_EFFORT,
durability=DurabilityPolicy.VOLATILE,
)
self.publisher_ = self.create_publisher(RTCM, '/fc_network/rtcm_input', rtcm_qos)
self._stop_event = threading.Event()
self._sock_lock = threading.Lock()
self._active_sock: Optional[socket.socket] = None
self._thread = threading.Thread(target=self._receive_loop, daemon=True)
self._thread.start()
self.get_logger().info('NtripClientNode 已啟動')
# ── NTRIP 連線 + RTCM 接收迴圈 ──────────────────────────────
def _request_stop(self):
self._stop_event.set()
with self._sock_lock:
if self._active_sock is not None:
try:
self._active_sock.close()
except OSError:
pass
def _receive_loop(self):
backoff = self.RECONNECT_BASE_SEC
host = self.get_parameter('host').value
port = self.get_parameter('port').value
mountpoint = self.get_parameter('mountpoint').value
user = self.get_parameter('username').value
pwd = self.get_parameter('password').value
if not mountpoint:
self.get_logger().error('mountpoint 參數未設定...')
return
while not self._stop_event.is_set():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
with self._sock_lock:
self._active_sock = sock
self.gga_lat = self.get_parameter('gga_lat').value
self.gga_lon = self.get_parameter('gga_lon').value
self.gga_alt_m = self.get_parameter('gga_alt_m').value
self.gga_interval_sec = self.get_parameter('gga_interval_sec').value
self.gga_on = self.get_parameter('gga_on').value
self.gga_on = self.gga_lat is not None and self.gga_lon is not None and self.gga_on
try:
self.get_logger().info(f'正在連線 {host}:{port}/{mountpoint} ...')
sock.connect((host, port))
auth = base64.b64encode(f'{user}:{pwd}'.encode()).decode()
request = (
f'GET /{mountpoint} HTTP/1.0\r\n'
f'User-Agent: NTRIP PythonClient\r\n'
f'Authorization: Basic {auth}\r\n'
f'Connection: close\r\n\r\n'
)
sock.sendall(request.encode())
resp = sock.recv(4096)
if b'ICY 200 OK' not in resp:
self.get_logger().error(
f'NTRIP 握手失敗: {resp.decode(errors="ignore").strip()}'
)
raise ConnectionError('handshake failed')
self.get_logger().info(f'已成功連接至 {mountpoint}')
backoff = self.RECONNECT_BASE_SEC
self._read_stream(sock)
except Exception as e:
if self._stop_event.is_set():
break
self.get_logger().warn(f'連線中斷: {e},{backoff:.0f}s 後重連')
if self._stop_event.wait(timeout=backoff):
break
backoff = min(backoff * 2, self.RECONNECT_MAX_SEC)
finally:
with self._sock_lock:
if self._active_sock is sock:
self._active_sock = None
try:
sock.close()
except OSError:
pass
def _read_stream(self, sock: socket.socket):
buf = b''
last_gga_time = 0.0
while not self._stop_event.is_set():
now = time.time()
if self.gga_on and (now - last_gga_time >= self.gga_interval_sec):
line = GGA_stream.send_gga(sock, self.gga_lat, self.gga_lon, self.gga_alt_m)
self.get_logger().info(f"[GGA] {line}")
last_gga_time = now
try:
chunk = sock.recv(4096)
except socket.timeout:
if self._stop_event.is_set():
break
continue
except OSError:
if self._stop_event.is_set():
break
raise
if not chunk:
if self._stop_event.is_set():
break
raise ConnectionError("stream ended. Empty Chunk")
buf += chunk
while len(buf) >= 6:
if buf[0] != 0xD3:
buf = buf[1:]
continue
payload_len = ((buf[1] & 0x03) << 8) | buf[2]
frame_len = payload_len + 6 # header(3) + payload + CRC(3)
if len(buf) < frame_len:
break
packet = buf[:frame_len]
buf = buf[frame_len:]
self._publish_rtcm(packet)
def _publish_rtcm(self, raw_packet: bytes):
msg = RTCM()
msg.header.stamp = self.get_clock().now().to_msg()
msg.data = list(raw_packet)
self.publisher_.publish(msg)
def destroy_node(self):
self._request_stop()
if self._thread.is_alive():
self._thread.join(timeout=5.0)
if self._thread.is_alive():
self.get_logger().warn('receive thread 未在時限內結束')
super().destroy_node()
def main(args=None):
rclpy.init(args=args)
node = NtripClientNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>fc_network_module</name>
<version>0.0.0</version>
<description>TODO: Package description</description>
<maintainer email="chiyu1468@hotmail.com">picars</maintainer>
<license>TODO: License declaration</license>
<depend>rclpy</depend>
<depend>mavros_msgs</depend>
<depend>std_msgs</depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
+4
View File
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/fc_network_module
[install]
install_scripts=$base/lib/fc_network_module
+26
View File
@@ -0,0 +1,26 @@
from setuptools import find_packages, setup
package_name = 'fc_network_module'
setup(
name=package_name,
version='0.0.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='picars',
maintainer_email='chiyu1468@hotmail.com',
description='TODO: Package description',
license='TODO: License declaration',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'ntrip_client = fc_network_module.ntrip_client:main',
],
},
)
@@ -0,0 +1,25 @@
# Copyright 2015 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from ament_copyright.main import main
import pytest
# Remove the `skip` decorator once the source file(s) have a copyright header
@pytest.mark.skip(reason='No copyright header has been placed in the generated source file.')
@pytest.mark.copyright
@pytest.mark.linter
def test_copyright():
rc = main(argv=['.', 'test'])
assert rc == 0, 'Found errors'
+25
View File
@@ -0,0 +1,25 @@
# Copyright 2017 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from ament_flake8.main import main_with_errors
import pytest
@pytest.mark.flake8
@pytest.mark.linter
def test_flake8():
rc, errors = main_with_errors(argv=[])
assert rc == 0, \
'Found %d code style errors / warnings:\n' % len(errors) + \
'\n'.join(errors)
+23
View File
@@ -0,0 +1,23 @@
# Copyright 2015 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from ament_pep257.main import main
import pytest
@pytest.mark.linter
@pytest.mark.pep257
def test_pep257():
rc = main(argv=['.', 'test'])
assert rc == 0, 'Found code style errors / warnings'
+30 -16
View File
@@ -1,21 +1,25 @@
備選需要的功能 備選需要的功能
- serail 對於 telemetry 的支援
- serial_manager.serial_object.transport 這些變數可能不需要 - serial_manager.serial_object.transport 這些變數可能不需要
不用動 不用動
- mavlink_object 的 send_message 確認一下 mavlink_bridge 的 _send_to_socket 是不是應該做成 async - mavlink_object 的 send_message 確認一下 mavlink_bridge 的 _send_to_socket 是不是應該做成 async
- 不同 socket 上面有重複的 sysid 分開儲存 (不做 不允許sysid重複)
這一步 這一步
研究 ros2 service RTK
跟 RTK2go 抓取列表 Done
下一步 從特定 mount point 得到數據 Done
做一個 ros2 service 接到數據並包裝 Done
下下一步 RTK 實機測試
下一步
重啟做好了 但是還沒自動化
下下一步
整合 xbee api-api 功能
後面 後面
rssi 資訊提取s
@@ -27,8 +31,24 @@ python -m fc_network_adapter.fc_network_adapter.mainOrchestrator
python -m someotherpkg.src.example_takeoff_land python -m someotherpkg.src.example_takeoff_land
python -m someotherpkg.src.example_change_mode python -m someotherpkg.src.example_change_mode
python -m fc_network_module.fc_network_module.ntrip_client --ros-args -p mountpoint:=No1bio_02 -p username:=chiyu1468@hotmail.com
python -m fc_network_module.fc_network_module.ntrip_client --ros-args -p host:=210.241.63.193 -p port:=81 -p mountpoint:=2020_GNSS -p username:=uavlab6061 -p password:=iamsupersmart
python3 -m fc_network_module.fc_network_module.ntrip_client --ros-args -p host:=210.241.63.193 -p port:=81 -p mountpoint:=2020_GNSS -p username:=uavlab6061 -p password:=iamsupersmart -p gga_on:=True -p gga_lat:=24.155792000 -p gga_lon:=120.630679000 -p gga_interval_sec:=60
watch GPS_RAW_INT
export ROS_DOMAIN_ID=1
ros2 daemon stop
ros2 daemon start
ros2 topic list ros2 topic list
ros2 topic echo /fc_network/vehicle/sys3/battery ros2 topic echo /fc_network/vehicle/sys3/battery
ros2 topic echo hz /fc_network/vehicle/sys3/battery
ros2 topic bw /fc_network/vehicle/sys3/attitude
ros2 topic hz /fc_network/vehicle/sys3/attitude
/home/picars/ardupilot/build/sitl/bin/arducopter -S --model + --speedup 1 --slave 0 --defaults /home/picars/ardupilot/Tools/autotest/default_params/copter.parm --sim-address=127.0.0.1 -I3 --sysid 7 /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
@@ -39,7 +59,7 @@ ros2 service call /mavlink/add_two_ints example_interfaces/srv/AddTwoInts "{a: 5
ros2 service call /fc_network/vehicle/mav_ping fc_interfaces/srv/MavPing "{target_sysid: 3, target_compid: 0, ping_seq: 1}" ros2 service call /fc_network/vehicle/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, param3: 0,param4: 0,param5: 0,param6: 0,param7: 0, timeout_sec: 2}"
ros2 service call /fc_network/vehicle/send_command_long fc_interfaces/srv/MavCommandLong "{target_sysid: 3, target_compid: 0, command: 176, confirmation: 0, param1: 1, param2: 4, timeout_sec: 2}" ros2 service call /fc_network/vehicle/send_command_long fc_interfaces/srv/MavCommandLong "{target_sysid: 10, target_compid: 0, command: 176, confirmation: 0, param1: 1, param2: 4, timeout_sec: 2}"
ros2 service call /fc_network/vehicle/pos_global_int fc_interfaces/srv/MavPositionTargetGlobalInt "{target_sysid: 3, target_compid: 1, coordinate_frame: 6, type_mask: 3576, lat_int: -35376655, lon_int: 149157011, alt: 20.0, timeout_sec: 5.0}" 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}"
@@ -48,10 +68,4 @@ sudo tcpdump -i lo 'udp dst port 14561' -X
sudo tcpdump -i lo 'udp dst port 14550' -X -vv sudo tcpdump -i lo 'udp dst port 14550' -X -vv
sudo tcpdump -i lo -X udp port 14550 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.